Snapshot friend_back worktree for local merge

Source HEAD: 9d7e220f14
Includes uncommitted project changes and new project files.
Align shared text line endings and executable flags with Friendship-and-chat-api.
Normalize new text to LF; exclude .workbuddy local assistant memory.
Original friend_back checkout is unchanged.
This commit is contained in:
Codex committed 2026-10-29 16:30:16 +08:00
1 parent 9d7e220f14
commit 04bac15e59
30 files changed
+811 -4

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""骑遇骑行记录:建表 + 模拟数据(chat_v1 库)。可重复执行(幂等:表已存在跳过,模拟数据按标题去重)。"""
import json
import math
import pymysql
DB = dict(host='139.224.25.207', port=3306, user='chat_v1',
password='BpJ2Ebt4zMPjBcCL', database='chat_v1', charset='utf8mb4',
connect_timeout=15)
DDL = [
"""
CREATE TABLE IF NOT EXISTS `member_ride_record` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
`user_id` bigint(20) NOT NULL COMMENT '骑手编号 member_user.id',
`title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '战报标题',
`road_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '路线名(如:南昆山)',
`corner_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '弯道名(如:观音潭右弯),空表示非跑山记录',
`distance_km` decimal(8,1) NOT NULL COMMENT '里程(公里)',
`duration_sec` int(11) NOT NULL COMMENT '骑行时长(秒)',
`top_speed_kmh` decimal(5,1) NOT NULL COMMENT '极速(km/h)',
`curve_count` int(11) NOT NULL DEFAULT '0' COMMENT '过弯次数',
`max_lean` decimal(4,1) NOT NULL COMMENT '最大压弯角度(度)',
`track_json` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '轨迹点 JSON [{lat,lng,speed}],仅详情接口返回',
`like_count` int(11) NOT NULL DEFAULT '0' COMMENT '点赞数(冗余计数)',
`report_count` int(11) NOT NULL DEFAULT '0' COMMENT '被举报次数',
`status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0 正常 1 已下架',
`tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '租户编号',
`creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_rank` (`road_name`, `corner_name`, `max_lean`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骑遇骑行记录(战报)'
""",
"""
CREATE TABLE IF NOT EXISTS `member_ride_record_like` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号',
`record_id` bigint(20) NOT NULL COMMENT '战报编号 member_ride_record.id',
`user_id` bigint(20) NOT NULL COMMENT '点赞人编号 member_user.id',
`tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '租户编号',
`creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_record_user` (`record_id`, `user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骑遇骑行战报点赞'
""",
]
# (user_id, title, road, corner, km, sec, top, curves, max_lean, days_ago, like_user_ids)
MOCK = [
(131, '南昆山 63 连弯,今天左弯终于不虚了', '南昆山', '观音潭右弯', 87, 5760, 138, 63, 52.0, 1, [133, 132, 134]),
(133, '清晨跑山,观音潭右弯刷出个人最佳', '南昆山', '观音潭右弯', 84, 5400, 132, 58, 49.5, 2, [131, 129]),
(129, '压弯一时爽,一直压弯一直爽', '南昆山', '观音潭右弯', 86, 5900, 129, 61, 46.0, 4, [131]),
(132, '第一次跑南昆山,右弯还是不敢深压', '南昆山', '观音潭右弯', 88, 6300, 112, 57, 33.5, 5, [134]),
(131, '连续 S 弯练车,大腿已废', '南昆山', '连续 S 弯', 52, 3900, 118, 41, 44.5, 1, [133, 129]),
(133, 'S 弯 41 连过,人车合一', '南昆山', '连续 S 弯', 52, 3950, 112, 41, 40.0, 3, [131, 134, 132]),
(129, '大夫山夜骑放风,发卡弯有点意思', '大夫山', '发卡弯', 44, 3660, 105, 38, 43.0, 1, [131, 133]),
(131, '大夫山发卡弯,极速压到 43 度', '大夫山', '发卡弯', 43, 3500, 108, 35, 43.2, 3, [133]),
(134, '傍晚放风,夕阳配弯道,舒服', '大夫山', '发卡弯', 44, 3800, 98, 36, 29.0, 4, [132]),
(131, '罗浮山风门坳长弯,全程 6 档', '罗浮山', '风门坳长弯', 121, 7680, 121, 22, 39.0, 2, [133, 129]),
(133, '风门坳长弯磨膝体验,就一次', '罗浮山', '风门坳长弯', 118, 7500, 126, 20, 47.5, 6, [131]),
(129, '周末摩旅,156 公里赶到营地', '', '', 156, 7680, 121, 22, 25.0, 3, [131, 133, 132, 134]),
(133, '市区通勤顺路兜风,绿道休闲骑', '', '', 32, 3000, 85, 12, 18.5, 1, [131]),
(132, '新手第一份战报,求带飞', '', '', 26, 2700, 76, 9, 15.0, 2, [134]),
]
def track_points(seed: int) -> str:
"""生成一个小环形轨迹 JSON(约 60 点),让战报详情有图可画。"""
r = 0.004 + (seed % 7) * 0.0004
pts = []
n = 60
for i in range(n):
t = i / n * math.pi * 4
radius = r + r * 0.35 * math.sin(t * 2.3 + seed)
lat = 23.17 + seed * 0.002 + radius * math.cos(t)
lng = 113.36 + seed * 0.003 + radius * 1.4 * math.sin(t)
speed = round(40 + 60 * (0.5 + 0.5 * math.sin(t * 1.7 + seed)), 1)
pts.append({'lat': round(lat, 6), 'lng': round(lng, 6), 'speed': speed})
return json.dumps(pts, separators=(',', ':'))
def main():
conn = pymysql.connect(**DB)
cur = conn.cursor()
for ddl in DDL:
cur.execute(ddl)
conn.commit()
print('DDL ok')
inserted = 0
for idx, (uid, title, road, corner, km, sec, top, curves, lean, days_ago, likers) in enumerate(MOCK):
cur.execute("SELECT id FROM member_ride_record WHERE title=%s AND deleted=0", (title,))
if cur.fetchone():
print('skip (exists):', title)
continue
cur.execute(
"""INSERT INTO member_ride_record
(user_id, title, road_name, corner_name, distance_km, duration_sec,
top_speed_kmh, curve_count, max_lean, track_json, like_count, report_count,
status, tenant_id, creator, create_time)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,1,%s,
DATE_SUB(NOW(), INTERVAL %s DAY) - INTERVAL %s HOUR)""",
(uid, title, road, corner, km, sec, top, curves, lean,
track_points(idx), len(likers), uid, days_ago, (idx * 3) % 20))
record_id = cur.lastrowid
for liker in likers:
cur.execute(
"""INSERT IGNORE INTO member_ride_record_like
(record_id, user_id, tenant_id, creator, create_time)
VALUES (%s,%s,1,%s, DATE_SUB(NOW(), INTERVAL 1 DAY))""",
(record_id, liker, uid))
inserted += 1
print('inserted:', title)
conn.commit()
cur.execute("SELECT COUNT(*) FROM member_ride_record WHERE deleted=0")
print('total records:', cur.fetchone()[0])
cur.execute("SELECT road_name, corner_name, COUNT(*) FROM member_ride_record "
"WHERE deleted=0 AND corner_name<>'' GROUP BY road_name, corner_name")
for r in cur.fetchall():
print('board:', r)
conn.close()
print('done, inserted =', inserted)
if __name__ == '__main__':
main()
@@ -0,0 +1,17 @@
-- 后台菜单与授权:子账号管理 + 会员管理拆分(男号/女号)
-- 执行库:chat_v1
-- 1) 新增菜单(幂等:先删后插)
DELETE FROM system_menu WHERE id IN (5810, 5820, 5821);
INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater, deleted)
VALUES
(5810, '子账号管理', 'system:sub-account:manage', 2, 20, 0, '/sub-account', 'ep:user', 'system/subaccount/index', 'SystemSubAccount', 0, b'1', b'1', b'1', '1', '1', b'0'),
(5820, '男号管理', 'member:user:query', 2, 1, 2262, 'male-user', 'ep:male', 'member/user/male', 'MemberUserMale', 0, b'1', b'1', b'1', '1', '1', b'0'),
(5821, '女号管理', 'member:user:query', 2, 2, 2262, 'female-user', 'ep:female', 'member/user/female', 'MemberUserFemale', 0, b'1', b'1', b'1', '1', '1', b'0');
-- 2) 授权给「APP管理员」角色(AppHolder 当前使用的角色)
DELETE FROM system_role_menu WHERE role_id = 158 AND menu_id IN (5810, 5820, 5821);
INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id)
VALUES
(158, 5810, '1', '1', b'0', 1),
(158, 5820, '1', '1', b'0', 1),
(158, 5821, '1', '1', b'0', 1);
+17
View File
@@ -0,0 +1,17 @@
-- 子账号管理:holder 与 子账号/专属角色 的绑定关系
-- 执行位置:chat_v1 库
CREATE TABLE IF NOT EXISTS `system_sub_account` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`holder_user_id` bigint NOT NULL COMMENT '管理者用户编号(system_users.id)',
`sub_user_id` bigint NOT NULL COMMENT '子账号用户编号(system_users.id)',
`role_id` bigint NOT NULL COMMENT '子账号专属角色编号(system_role.id)',
`creator` varchar(64) NULL DEFAULT '' COMMENT '创建者',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` varchar(64) NULL DEFAULT '' COMMENT '更新者',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
PRIMARY KEY (`id`),
KEY `idx_holder_user_id` (`holder_user_id`),
KEY `idx_sub_user_id` (`sub_user_id`)
) ENGINE = InnoDB COMMENT '后台子账号表';
@@ -41,6 +41,12 @@ public class MemberUserPageReqVO extends PageParam {
@Schema(description = "用户分组编号", example = "1")
private Long groupId;
@Schema(description = "用户 Code(App 我的页面展示的 ID)", example = "924199")
private String userCode;
@Schema(description = "性别:1 男 2 女", example = "1")
private Integer sex;
// TODO 芋艿:注册用户类型;
// TODO 芋艿:登录用户类型;
@@ -92,6 +92,8 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
.betweenIfPresent(MemberUserDO::getCreateTime, reqVO.getCreateTime())
.eqIfPresent(MemberUserDO::getLevelId, reqVO.getLevelId())
.eqIfPresent(MemberUserDO::getGroupId, reqVO.getGroupId())
.eqIfPresent(MemberUserDO::getUserCode, reqVO.getUserCode())
.eqIfPresent(MemberUserDO::getSex, reqVO.getSex())
.apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql)
.orderByDesc(MemberUserDO::getId));
}
@@ -63,6 +63,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.member.dal.mysql.recent.RecentMapper;
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -83,6 +84,14 @@ public class RecentServiceImpl implements RecentService {
@Resource
private RecentMapper recentMapper;
@Resource
private MemberUserMapper memberUserMapper;
/**
* 首页(广场/附近)用户列表不可见账号:这些手机号的用户不会出现在首页用户列表中。
* 如需增减隐藏账号,改这个列表即可。
*/
private static final List<String> SQUARE_HIDDEN_MOBILES = Arrays.asList("13311615193", "18800000001");
@Resource
private OperationMapper operationMapper;
@Resource
@@ -497,6 +506,18 @@ public class RecentServiceImpl implements RecentService {
RecentPageReqVO resultQuery = BeanUtil.toBean(bean, RecentPageReqVO.class);
resultQuery.setSceneBizType(bean.getScene().getType());
quickBeforeQuery(operationUser, operationCfg, resultQuery);
// 首页不可见账号:按手机号定位,合并进排除列表(广场/附近所有 tab 均不出现)
List<Long> excludeIds = resultQuery.getExcludeUserIdList() != null
? new ArrayList<>(resultQuery.getExcludeUserIdList()) : new ArrayList<>();
for (String hiddenMobile : SQUARE_HIDDEN_MOBILES) {
MemberUserDO hiddenUser = memberUserMapper.selectByMobile(hiddenMobile);
if (hiddenUser != null && !excludeIds.contains(hiddenUser.getId())) {
excludeIds.add(hiddenUser.getId());
}
}
if (!excludeIds.isEmpty()) {
resultQuery.setExcludeUserIdList(excludeIds);
}
// 广场-最近
if (ObjectUtil.equals(bean.getScene(), MemberRecentSceneEnum.SQUARE_NEW_TIME)) {
resultQuery.setDistanceIsNotNull(true);
@@ -167,4 +167,10 @@ public interface ErrorCodeConstants {
// ========== 站内信发送 1-002-028-000 ==========
ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失");
// ========== 子账号管理 1-002-030-000 =========
ErrorCode SUB_ACCOUNT_NOT_EXISTS = new ErrorCode(1_002_030_000, "子账号不存在");
ErrorCode SUB_ACCOUNT_NOT_OWNER = new ErrorCode(1_002_030_001, "无权操作该子账号");
ErrorCode SUB_ACCOUNT_MENU_EMPTY = new ErrorCode(1_002_030_002, "请至少勾选一个当前账号拥有的菜单");
ErrorCode SUB_ACCOUNT_HOLDER_MENU_EMPTY = new ErrorCode(1_002_030_003, "当前账号未拥有任何菜单,无法创建子账号");
}
@@ -0,0 +1,88 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.*;
import cn.iocoder.yudao.module.system.service.subaccount.SubAccountService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 后台子账号管理 Controller
*
* 供 AppHolder 管理自己的子账号:创建、分配菜单、禁用、重置密码、删除。
*/
@Tag(name = "管理后台 - 子账号管理")
@RestController
@RequestMapping("/system/sub-account")
@Validated
public class SubAccountController {
@Resource
private SubAccountService subAccountService;
@GetMapping("/list")
@Operation(summary = "获得子账号列表")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<List<SubAccountRespVO>> getSubAccountList() {
return success(subAccountService.getSubAccountList());
}
@GetMapping("/menu-list")
@Operation(summary = "获得当前账号拥有的菜单(用于给子账号勾选)")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<List<SubAccountMenuRespVO>> getHolderMenuList() {
return success(subAccountService.getHolderMenuList());
}
@PostMapping("/create")
@Operation(summary = "创建子账号")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<Long> createSubAccount(@Valid @RequestBody SubAccountCreateReqVO reqVO) {
return success(subAccountService.createSubAccount(reqVO, reqVO.getPassword()));
}
@PutMapping("/update")
@Operation(summary = "修改子账号(昵称 + 可见菜单)")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<Boolean> updateSubAccount(@RequestParam("userId") Long userId,
@Valid @RequestBody SubAccountSaveReqVO reqVO) {
subAccountService.updateSubAccount(userId, reqVO);
return success(true);
}
@PutMapping("/update-status")
@Operation(summary = "修改子账号状态")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<Boolean> updateSubAccountStatus(@Valid @RequestBody SubAccountUpdateStatusReqVO reqVO) {
subAccountService.updateSubAccountStatus(reqVO.getUserId(), reqVO.getStatus());
return success(true);
}
@PutMapping("/reset-password")
@Operation(summary = "重置子账号密码")
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<Boolean> resetSubAccountPassword(@Valid @RequestBody SubAccountResetPasswordReqVO reqVO) {
subAccountService.resetSubAccountPassword(reqVO.getUserId(), reqVO.getPassword());
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除子账号")
@Parameter(name = "userId", description = "子账号用户编号", required = true)
@PreAuthorize("@ss.hasPermission('system:sub-account:manage')")
public CommonResult<Boolean> deleteSubAccount(@RequestParam("userId") Long userId) {
subAccountService.deleteSubAccount(userId);
return success(true);
}
}
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.hibernate.validator.constraints.Length;
@Schema(description = "管理后台 - 子账号创建 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class SubAccountCreateReqVO extends SubAccountSaveReqVO {
@Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
}
@@ -0,0 +1,25 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - 子账号可用菜单 Response VO")
@Data
public class SubAccountMenuRespVO {
@Schema(description = "菜单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2262")
private Long id;
@Schema(description = "父菜单编号", example = "0")
private Long parentId;
@Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "会员中心")
private String name;
@Schema(description = "菜单类型:1 目录 2 菜单 3 按钮", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer type;
@Schema(description = "显示顺序", example = "1")
private Integer sort;
}
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
@Schema(description = "管理后台 - 子账号重置密码 Request VO")
@Data
public class SubAccountResetPasswordReqVO {
@Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
@NotNull(message = "子账号编号不能为空")
private Long userId;
@Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "新密码不能为空")
@Size(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;
}
@@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Set;
@Schema(description = "管理后台 - 子账号 Response VO")
@Data
public class SubAccountRespVO {
@Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id;
@Schema(description = "子账号用户编号(system_users.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
private Long userId;
@Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "suboperator")
private String username;
@Schema(description = "昵称", example = "运营小号")
private String nickname;
@Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status;
@Schema(description = "最后登录时间")
private LocalDateTime loginDate;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "可见菜单编号列表")
private Set<Long> menuIds;
}
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Set;
@Schema(description = "管理后台 - 子账号创建/修改 Request VO")
@Data
public class SubAccountSaveReqVO {
@Schema(description = "子账号用户名(创建必填,字母+数字 4-30 位)", example = "suboperator")
@Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符")
private String username;
@Schema(description = "子账号昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "运营小号")
@NotBlank(message = "子账号昵称不能为空")
@Size(max = 30, message = "子账号昵称长度不能超过30个字符")
private String nickname;
@Schema(description = "菜单编号列表(必须在当前登录账号拥有的菜单范围内)", example = "[2262, 2317]")
private Set<Long> menuIds;
}
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotNull;
@Schema(description = "管理后台 - 子账号状态修改 Request VO")
@Data
public class SubAccountUpdateStatusReqVO {
@Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200")
@NotNull(message = "子账号编号不能为空")
private Long userId;
@Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "状态不能为空")
private Integer status;
}
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.system.dal.dataobject.subaccount;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 后台子账号 DO
*
* AppHolder 创建的子账号与专属角色的绑定关系。
* 子账号登录后台后可见的菜单 = 其专属角色被分配的菜单(由 holder 分配)。
*/
@TableName("system_sub_account")
@KeySequence("system_sub_account_seq")
@Data
@EqualsAndHashCode(callSuper = true)
public class SubAccountDO extends TenantBaseDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 管理者(holder)用户编号,system_users.id
*/
private Long holderUserId;
/**
* 子账号用户编号,system_users.id
*/
private Long subUserId;
/**
* 子账号专属角色编号,system_role.id
*/
private Long roleId;
}
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.system.dal.mysql.subaccount;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 后台子账号 Mapper
*/
@Mapper
public interface SubAccountMapper extends BaseMapperX<SubAccountDO> {
default List<SubAccountDO> selectListByHolderUserId(Long holderUserId) {
return selectList(new LambdaQueryWrapperX<SubAccountDO>()
.eq(SubAccountDO::getHolderUserId, holderUserId)
.orderByDesc(SubAccountDO::getId));
}
default SubAccountDO selectBySubUserId(Long subUserId) {
return selectOne(SubAccountDO::getSubUserId, subUserId);
}
}
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.system.service.subaccount;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO;
import java.util.List;
/**
* 后台子账号 Service 接口
*
* AppHolder 在自己的权限范围内创建子账号,并分配子账号可见的菜单。
*/
public interface SubAccountService {
/**
* 获得当前登录账号(holder)的子账号列表
*
* @return 子账号列表
*/
List<SubAccountRespVO> getSubAccountList();
/**
* 获得当前登录账号(holder)拥有的菜单(用于给子账号勾选)
*
* @return 菜单列表
*/
List<SubAccountMenuRespVO> getHolderMenuList();
/**
* 创建子账号(用户名 + 昵称 + 密码 + 可见菜单)
*
* @param reqVO 创建信息(password 需要传)
* @return 子账号用户编号
*/
Long createSubAccount(SubAccountSaveReqVO reqVO, String password);
/**
* 修改子账号(昵称 + 可见菜单)
*
* @param userId 子账号用户编号
* @param reqVO 修改信息
*/
void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO);
/**
* 修改子账号状态(禁用后无法登录后台)
*
* @param userId 子账号用户编号
* @param status 状态
*/
void updateSubAccountStatus(Long userId, Integer status);
/**
* 重置子账号密码
*
* @param userId 子账号用户编号
* @param password 新密码
*/
void resetSubAccountPassword(Long userId, String password);
/**
* 删除子账号(连同专属角色)
*
* @param userId 子账号用户编号
*/
void deleteSubAccount(Long userId);
}
@@ -0,0 +1,230 @@
package cn.iocoder.yudao.module.system.service.subaccount;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.permission.vo.role.RoleSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO;
import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO;
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO;
import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO;
import cn.iocoder.yudao.module.system.dal.mysql.subaccount.SubAccountMapper;
import cn.iocoder.yudao.module.system.enums.permission.RoleTypeEnum;
import cn.iocoder.yudao.module.system.service.permission.MenuService;
import cn.iocoder.yudao.module.system.service.permission.PermissionService;
import cn.iocoder.yudao.module.system.service.permission.RoleService;
import cn.iocoder.yudao.module.system.service.tenant.TenantService;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
/**
* 后台子账号 Service 实现类
*/
@Service
@Validated
public class SubAccountServiceImpl implements SubAccountService {
@Resource
private SubAccountMapper subAccountMapper;
@Resource
private AdminUserService userService;
@Resource
private RoleService roleService;
@Resource
private PermissionService permissionService;
@Resource
private MenuService menuService;
@Resource
private TenantService tenantService;
@Override
public List<SubAccountRespVO> getSubAccountList() {
Long holderUserId = getLoginUserId();
List<SubAccountDO> mappings = subAccountMapper.selectListByHolderUserId(holderUserId);
List<SubAccountRespVO> result = new ArrayList<>();
for (SubAccountDO mapping : mappings) {
AdminUserDO user = userService.getUser(mapping.getSubUserId());
if (user == null) { // 用户已被删除,跳过
continue;
}
SubAccountRespVO vo = BeanUtils.toBean(mapping, SubAccountRespVO.class);
vo.setUserId(user.getId());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
vo.setStatus(user.getStatus());
vo.setLoginDate(user.getLoginDate());
vo.setMenuIds(permissionService.getRoleMenuListByRoleId(mapping.getRoleId()));
result.add(vo);
}
return result;
}
@Override
public List<SubAccountMenuRespVO> getHolderMenuList() {
Set<Long> holderMenuIds = getHolderMenuIds(getLoginUserId());
if (CollUtil.isEmpty(holderMenuIds)) {
return new ArrayList<>();
}
List<MenuDO> menus = menuService.getMenuList(holderMenuIds);
menus = menuService.filterDisableMenus(menus);
List<SubAccountMenuRespVO> result = new ArrayList<>();
for (MenuDO menu : menus) {
SubAccountMenuRespVO vo = new SubAccountMenuRespVO();
vo.setId(menu.getId());
vo.setParentId(menu.getParentId());
vo.setName(menu.getName());
vo.setType(menu.getType());
vo.setSort(menu.getSort());
result.add(vo);
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createSubAccount(SubAccountSaveReqVO reqVO, String password) {
Long holderUserId = getLoginUserId();
// 1. 校验账号唯一
if (userService.getUserByUsername(reqVO.getUsername()) != null) {
throw exception(USER_USERNAME_EXISTS);
}
// 2. 校验并过滤菜单:只能授予 holder 自己拥有的菜单
Set<Long> menuIds = filterMenusByHolder(reqVO.getMenuIds(), holderUserId);
// 3. 创建子账号用户
UserSaveReqVO userReqVO = new UserSaveReqVO();
userReqVO.setUsername(reqVO.getUsername());
userReqVO.setNickname(reqVO.getNickname());
userReqVO.setPassword(password);
userReqVO.setRemark("AppHolder 子账号");
Long subUserId = userService.createUser(userReqVO);
// 4. 创建专属角色并分配菜单
Long roleId = createSubRole(subUserId, reqVO.getNickname());
permissionService.assignRoleMenu(roleId, menuIds);
// 5. 绑定角色
permissionService.assignUserRole(subUserId, CollUtil.newHashSet(roleId));
// 6. 记录绑定关系
SubAccountDO mapping = new SubAccountDO();
mapping.setHolderUserId(holderUserId);
mapping.setSubUserId(subUserId);
mapping.setRoleId(roleId);
subAccountMapper.insert(mapping);
return subUserId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO) {
SubAccountDO mapping = validateOwner(userId);
// 1. 更新昵称(username 不允许修改,密码走独立接口)
AdminUserDO user = userService.getUser(userId);
UserSaveReqVO userReqVO = new UserSaveReqVO();
userReqVO.setId(userId);
userReqVO.setUsername(user.getUsername());
userReqVO.setNickname(reqVO.getNickname());
userService.updateUser(userReqVO);
// 2. 更新可见菜单
Set<Long> menuIds = filterMenusByHolder(reqVO.getMenuIds(), mapping.getHolderUserId());
permissionService.assignRoleMenu(mapping.getRoleId(), menuIds);
}
@Override
public void updateSubAccountStatus(Long userId, Integer status) {
validateOwner(userId);
userService.updateUserStatus(userId, status);
}
@Override
public void resetSubAccountPassword(Long userId, String password) {
validateOwner(userId);
userService.updateUserPassword(userId, password);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteSubAccount(Long userId) {
SubAccountDO mapping = validateOwner(userId);
// 1. 删除用户
userService.deleteUser(userId);
// 2. 删除专属角色
roleService.deleteRole(mapping.getRoleId());
// 3. 删除绑定关系
subAccountMapper.deleteById(mapping.getId());
}
private Long createSubRole(Long subUserId, String nickname) {
RoleSaveReqVO roleReqVO = new RoleSaveReqVO();
roleReqVO.setName("子账号-" + nickname);
roleReqVO.setCode("sub_acc_" + subUserId + "_" + RandomUtil.randomString(6).toLowerCase());
roleReqVO.setSort(999);
roleReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
roleReqVO.setRemark("AppHolder 子账号专属角色");
return roleService.createRole(roleReqVO, RoleTypeEnum.CUSTOM.getType());
}
/**
* 校验当前登录人是否为该子账号的管理者,并返回绑定关系
*/
private SubAccountDO validateOwner(Long subUserId) {
SubAccountDO mapping = subAccountMapper.selectBySubUserId(subUserId);
if (mapping == null) {
throw exception(SUB_ACCOUNT_NOT_EXISTS);
}
if (!mapping.getHolderUserId().equals(getLoginUserId())) {
throw exception(SUB_ACCOUNT_NOT_OWNER);
}
return mapping;
}
/**
* 获得 holder 拥有的菜单编号集合(其所有角色菜单的并集)
*/
private Set<Long> getHolderMenuIds(Long holderUserId) {
Set<Long> roleIds = permissionService.getUserRoleIdListByUserId(holderUserId);
if (CollUtil.isEmpty(roleIds)) {
return new HashSet<>();
}
return permissionService.getRoleMenuListByRoleId(roleIds);
}
/**
* 将请求的菜单过滤为 holder 自己拥有的菜单;若过滤后为空则报错
*/
private Set<Long> filterMenusByHolder(Set<Long> requestMenuIds, Long holderUserId) {
Set<Long> holderMenuIds = getHolderMenuIds(holderUserId);
if (CollUtil.isEmpty(holderMenuIds)) {
throw exception(SUB_ACCOUNT_HOLDER_MENU_EMPTY);
}
Set<Long> menuIds = new HashSet<>();
if (CollUtil.isNotEmpty(requestMenuIds)) {
tenantService.handleTenantMenu(menuIds0 -> {
for (Long menuId : requestMenuIds) {
if (holderMenuIds.contains(menuId) && menuIds0.contains(menuId)) {
menuIds.add(menuId);
}
}
});
}
if (CollUtil.isEmpty(menuIds)) {
throw exception(SUB_ACCOUNT_MENU_EMPTY);
}
return menuIds;
}
}
@@ -50,12 +50,12 @@ spring:
master:
url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: chat_v1
password: BpJ2Ebt4zMPjBcCL
password: rfBzbFFdW4kepybz
slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
username: chat_v1
password: BpJ2Ebt4zMPjBcCL
password: rfBzbFFdW4kepybz
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
redis:
@@ -47,12 +47,12 @@ spring:
master:
url: jdbc:mysql://127.0.0.1:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: chat_v1
password: BpJ2Ebt4zMPjBcCL
password: rfBzbFFdW4kepybz
slave: # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://192.168.0.7:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
username: chat_v1
password: BpJ2Ebt4zMPjBcCL
password: rfBzbFFdW4kepybz
# Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优
redis:
host: 127.0.0.1 # 地址