更新
This commit is contained in:
@@ -241,7 +241,7 @@ func SerializeMapOnRead(ctx context.Context, data map[string]any, fieldDict map[
|
||||
|
||||
// LogTypeText 操作日志类型转中文名
|
||||
//
|
||||
// 该字段存储的是注解常量值(1=新增…40=解绑),与字典编码体系不一致,
|
||||
// 该字段存储的是标签常量值(1=新增…40=解绑),与字典编码体系不一致,
|
||||
// 故使用静态映射表而非字典;未知值回退原数字。
|
||||
func LogTypeText(v int) string {
|
||||
if n, ok := consts.LogTypeNames[v]; ok {
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"easygoadmin/api/v1"
|
||||
"easygoadmin/internal/dao"
|
||||
"easygoadmin/internal/service"
|
||||
"easygoadmin/utility/upload"
|
||||
)
|
||||
|
||||
// ============================== 登录日志业务实现 ==============================
|
||||
@@ -72,6 +73,44 @@ var loginLogSpec = ModuleSpec[v1.LoginLogItem, v1.LoginLogItem, v1.LoginLogDetai
|
||||
EqFields: []string{"log_type", "status"},
|
||||
OrderBy: "id DESC",
|
||||
SerializeMaps: loginLogSerializeMaps,
|
||||
// 头像按登录账号用户名回查补齐(失败登录无对应用户时留空)
|
||||
AfterPageList: func(ctx context.Context, list []*v1.LoginLogItem, req any) error {
|
||||
return fillLoginLogAvatars(ctx, list)
|
||||
},
|
||||
AfterDetail: func(ctx context.Context, item *v1.LoginLogDetailRes, req any) error {
|
||||
return fillLoginLogAvatars(ctx, []*v1.LoginLogItem{&item.LoginLogItem})
|
||||
},
|
||||
}
|
||||
|
||||
// fillLoginLogAvatars 按登录账号用户名批量查询并回填头像
|
||||
//
|
||||
// 日志表不冗余存头像:读侧将用户名去重后一次 IN 查询用户表的
|
||||
// username/avatar 相对路径,经 upload.GetFileUrl 补全访问域名;
|
||||
// 查询失败原样上抛。无有效用户名或用户不存在(登录失败记录)时留空。
|
||||
func fillLoginLogAvatars(ctx context.Context, items []*v1.LoginLogItem) error {
|
||||
nameMap := map[string]bool{}
|
||||
names := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.Username != "" && !nameMap[it.Username] {
|
||||
nameMap[it.Username] = true
|
||||
names = append(names, it.Username)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
avatarMap := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Avatar = avatarMap[items[i].Username]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -95,7 +134,15 @@ func (s *sLoginLog) List(ctx context.Context, req *v1.LoginLogListReq) (res v1.L
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return derefSlice(list), nil
|
||||
rows := derefSlice(list)
|
||||
ptrs := make([]*v1.LoginLogItem, len(rows))
|
||||
for i := range rows {
|
||||
ptrs[i] = &rows[i]
|
||||
}
|
||||
if err := fillLoginLogAvatars(ctx, ptrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Detail 登录日志详情
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"easygoadmin/internal/dao"
|
||||
"easygoadmin/internal/middleware"
|
||||
"easygoadmin/internal/service"
|
||||
"easygoadmin/utility/upload"
|
||||
)
|
||||
|
||||
// ============================== 操作日志业务实现 ==============================
|
||||
@@ -70,19 +71,50 @@ var operLogSpec = ModuleSpec[v1.OperLogItem, v1.OperLogItem, v1.OperLogDetailRes
|
||||
EqFields: []string{"type", "status"},
|
||||
OrderBy: "id DESC",
|
||||
SerializeMaps: operLogSerializeMaps,
|
||||
// typeText 走静态映射(注解常量值,与字典体系无关)
|
||||
// typeText 走静态映射(标签常量值,与字典体系无关);头像按操作人用户名回查补齐
|
||||
AfterPageList: func(ctx context.Context, list []*v1.OperLogItem, req any) error {
|
||||
for _, item := range list {
|
||||
item.TypeText = LogTypeText(item.Type)
|
||||
}
|
||||
return nil
|
||||
return fillOperLogAvatars(ctx, list)
|
||||
},
|
||||
AfterDetail: func(ctx context.Context, item *v1.OperLogDetailRes, req any) error {
|
||||
item.TypeText = LogTypeText(item.Type)
|
||||
return nil
|
||||
return fillOperLogAvatars(ctx, []*v1.OperLogItem{&item.OperLogItem})
|
||||
},
|
||||
}
|
||||
|
||||
// fillOperLogAvatars 按操作人用户名批量查询并回填头像
|
||||
//
|
||||
// 日志表只落 create_user(用户名),头像不冗余存储:读侧将用户名去重后
|
||||
// 一次 IN 查询用户表的 username/avatar 相对路径,经 upload.GetFileUrl
|
||||
// 补全访问域名;查询失败原样上抛。无有效用户名(未认证请求)时直接返回。
|
||||
func fillOperLogAvatars(ctx context.Context, items []*v1.OperLogItem) error {
|
||||
nameMap := map[string]bool{}
|
||||
names := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
if it.CreateUser != "" && !nameMap[it.CreateUser] {
|
||||
nameMap[it.CreateUser] = true
|
||||
names = append(names, it.CreateUser)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := dao.User.Ctx(ctx).WhereIn("username", names).Fields("username, avatar").All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
avatarMap := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
avatarMap[r["username"].String()] = upload.GetFileUrl(ctx, r["avatar"].String())
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Avatar = avatarMap[items[i].CreateUser]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterOperLog(&sOperLog{})
|
||||
// 操作日志中间件落库接线(异步批量,见 middleware/operation_log.go)
|
||||
@@ -131,10 +163,16 @@ func (s *sOperLog) List(ctx context.Context, req *v1.OperLogListReq) (res v1.Ope
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range list {
|
||||
item.TypeText = LogTypeText(item.Type)
|
||||
rows := derefSlice(list)
|
||||
ptrs := make([]*v1.OperLogItem, len(rows))
|
||||
for i := range rows {
|
||||
rows[i].TypeText = LogTypeText(rows[i].Type)
|
||||
ptrs[i] = &rows[i]
|
||||
}
|
||||
return derefSlice(list), nil
|
||||
if err := fillOperLogAvatars(ctx, ptrs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Detail 操作日志详情
|
||||
|
||||
+40
-68
@@ -55,7 +55,6 @@ import (
|
||||
"easygoadmin/internal/dao"
|
||||
"easygoadmin/internal/service"
|
||||
"easygoadmin/utility/cache"
|
||||
"easygoadmin/utility/crypto"
|
||||
)
|
||||
|
||||
// ============================== 租户业务实现 ==============================
|
||||
@@ -307,80 +306,53 @@ func (s *sTenant) Add(ctx context.Context, req *v1.TenantAddReq) (res *v1.IdRes,
|
||||
return &v1.IdRes{Id: idv}, nil
|
||||
}
|
||||
|
||||
// Account 创建租户账号
|
||||
// Account 添加租户账号
|
||||
//
|
||||
// 同时创建租户及其初始管理员账号;
|
||||
// bcrypt 属 CPU 密集操作,放在事务外预计算,保持短事务
|
||||
// 在指定租户(req.TenantId)下创建用户账号,返回目标租户ID、租户编码与新用户ID。
|
||||
// 平台级专属:拦截租户侧身份——本操作按请求显式指定的目标租户写入,
|
||||
// 且唯一性校验/用户数上限的查询链依赖平台视角(无租户收敛)。
|
||||
// 目标租户不存在返回 CodeTenantNotFound;账号唯一性、用户数上限、编码生成、
|
||||
// 密码加盐、头像转正、角色关联复用用户新增链路(sUser.addWithTenant)。
|
||||
func (s *sTenant) Account(ctx context.Context, req *v1.TenantAccountReq) (res *v1.TenantAccountRes, err error) {
|
||||
// 租户:自增回填(临时编码占位 → T%04d 修正)
|
||||
tempCode := fmt.Sprintf("T%010d", time.Now().UnixNano()%10000000000)
|
||||
tData := map[string]any{
|
||||
"name": req.Name,
|
||||
"code": tempCode,
|
||||
"contact": req.Contact,
|
||||
"mobile": req.Mobile,
|
||||
"email": req.Email,
|
||||
"address": req.Address,
|
||||
"status": 1,
|
||||
"max_user_num": req.MaxUserNum,
|
||||
"note": req.Note,
|
||||
if !consts.IsPlatform(ctx) {
|
||||
return nil, gerror.NewCode(consts.CodeForbidden)
|
||||
}
|
||||
if req.ExpireTime != "" {
|
||||
if t := gtime.New(req.ExpireTime); t != nil {
|
||||
tData["expire_time"] = t
|
||||
}
|
||||
// 目标租户存在性校验(同时取编码供响应回传)
|
||||
one, e := dao.Tenant.Ctx(ctx).Where("id", req.TenantId).Fields("code").One()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
// bcrypt 属 CPU 密集操作,放在事务外预计算(短事务纪律)
|
||||
salt := crypto.GenerateSalt()
|
||||
hashed, err := crypto.HashPassword(req.Password, salt)
|
||||
if len(one) == 0 {
|
||||
return nil, gerror.NewCode(consts.CodeTenantNotFound)
|
||||
}
|
||||
// 用户表单字段转用户新增入参,按目标租户落库
|
||||
idRes, err := (&sUser{}).addWithTenant(ctx, req.TenantId, &v1.UserAddReq{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Realname: req.Realname,
|
||||
Gender: req.Gender,
|
||||
Mobile: req.Mobile,
|
||||
Email: req.Email,
|
||||
Roles: req.Roles,
|
||||
DeptId: req.DeptId,
|
||||
LevelId: req.LevelId,
|
||||
PositionId: req.PositionId,
|
||||
City: req.City,
|
||||
Address: req.Address,
|
||||
Avatar: req.Avatar,
|
||||
Intro: req.Intro,
|
||||
Status: req.Status,
|
||||
Sort: req.Sort,
|
||||
Note: req.Note,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
realname := req.Realname
|
||||
if realname == "" {
|
||||
realname = req.Username
|
||||
}
|
||||
|
||||
// 租户 + 初始管理员三段写入保持原子性(全部经 txCtx)
|
||||
var tenantId, uid int64
|
||||
var code string
|
||||
if err = WithTrans(ctx, func(txCtx context.Context) error {
|
||||
tResult, e := dao.Tenant.Ctx(txCtx).Data(tData).Insert()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
tenantId, _ = tResult.LastInsertId()
|
||||
code = fmt.Sprintf("T%04d", tenantId)
|
||||
if _, e = dao.Tenant.Ctx(txCtx).Where("id", tenantId).Data(map[string]any{"code": code}).Update(); e != nil {
|
||||
return e
|
||||
}
|
||||
// 初始管理员(tenant_id 由 hooks 自动填充)
|
||||
tenantCtx := consts.WithTenantID(txCtx, int(tenantId))
|
||||
uResult, e := dao.User.Ctx(tenantCtx).Data(map[string]any{
|
||||
"username": req.Username,
|
||||
"code": fmt.Sprintf("%s-000001", code), // 首个用户序号 000001
|
||||
"password": hashed,
|
||||
"salt": salt,
|
||||
"realname": realname,
|
||||
"mobile": req.Mobile,
|
||||
"email": req.Email,
|
||||
"province_code": "",
|
||||
"city_code": "",
|
||||
"district_code": "",
|
||||
"street_code": "",
|
||||
"address": "",
|
||||
"status": 1,
|
||||
"tenant_id": int(tenantId),
|
||||
}).Insert()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
uid, _ = uResult.LastInsertId()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v1.TenantAccountRes{TenantId: tenantId, Code: code, UserId: uid}, nil
|
||||
return &v1.TenantAccountRes{
|
||||
TenantId: int64(req.TenantId),
|
||||
Code: one["code"].String(),
|
||||
UserId: idRes.Id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Users 查询租户用户列表
|
||||
|
||||
+13
-2
@@ -277,9 +277,20 @@ func (s *sUser) Detail(ctx context.Context, req *v1.UserDetailReq) (res *v1.User
|
||||
|
||||
// Add 新增用户
|
||||
//
|
||||
// 用户名与手机号租户内唯一,校验租户用户上限,自动生成用户编码并写入 user_role
|
||||
// 入参 *v1.UserAddReq;返回新记录ID供前端刷新定位。
|
||||
// 租户取当前登录租户,落库链路见 addWithTenant。
|
||||
func (s *sUser) Add(ctx context.Context, req *v1.UserAddReq) (res *v1.IdRes, err error) {
|
||||
tenantId := consts.GetTenantID(ctx)
|
||||
return s.addWithTenant(ctx, consts.GetTenantID(ctx), req)
|
||||
}
|
||||
|
||||
// addWithTenant 在指定租户下创建用户(Add 与租户账号添加的共用实现)
|
||||
//
|
||||
// tenantId 为目标租户ID:Add 传当前登录租户,租户账号添加(Tenant.Account)
|
||||
// 传请求携带的目标租户。顺序契约:用户名/手机号租户内唯一校验 → 租户用户数
|
||||
// 上限校验 → 生成用户编码 → 密码双重加盐 → 组装落库 map → 头像 temp 转正 →
|
||||
// 写入 gin_user → 重建角色关联;重复返回 CodeRecordExists,超限返回
|
||||
// CodeTenantUserLimit,查询/加密错误原样上抛。
|
||||
func (s *sUser) addWithTenant(ctx context.Context, tenantId int, req *v1.UserAddReq) (res *v1.IdRes, err error) {
|
||||
if exists, e := s.usernameExists(ctx, req.Username, tenantId, 0); e != nil {
|
||||
return nil, e
|
||||
} else if exists {
|
||||
|
||||
Reference in New Issue
Block a user