feat: 支持管理员创建终端用户 (#73)
This commit is contained in:
@@ -23,7 +23,9 @@ export const createRoutePool = data => request({ url: `${base}/route-pools`, met
|
||||
export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools/${id}`, method: 'put', data })
|
||||
|
||||
export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' })
|
||||
export const createPortalUser = data => request({ url: `${base}/users`, method: 'post', data })
|
||||
export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }})
|
||||
export const resetPortalUserPassword = (id, password) => request({ url: `${base}/users/${id}/password`, method: 'put', data: { password }})
|
||||
export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' })
|
||||
export const getGeneration = id => request({ url: `${base}/generations/${id}`, method: 'get' })
|
||||
export const getGenerationMedia = url => request({ url, method: 'get', responseType: 'blob' })
|
||||
|
||||
@@ -1,30 +1,120 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper><main class="chorus-page">
|
||||
<header class="page-heading"><div><h1>用户管理</h1><p>查看终端用户账号和状态;管理员账号在系统管理中单独维护。</p></div><el-button :loading="loading" @click="load"><i class="ri-refresh-line" /> 刷新</el-button></header>
|
||||
<div class="toolbar"><el-input v-model.trim="keyword" clearable placeholder="用户编号 / 账号 / 昵称 / 邮箱" style="width: 320px" /><el-select v-model="status" clearable placeholder="全部状态" style="width: 140px"><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select><el-button @click="reset">重置</el-button><span class="spacer" /><span class="muted">共 {{ filtered.length }} 条</span></div>
|
||||
<section class="table-panel"><el-table v-loading="loading" :data="filtered" border empty-text="暂无终端用户">
|
||||
<el-table-column prop="id" label="用户编号" width="110"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column><el-table-column prop="username" label="账号" min-width="150"><template #default="{ row }"><span class="mono">{{ row.username }}</span></template></el-table-column><el-table-column prop="display_name" label="昵称" min-width="150" /><el-table-column prop="email" label="邮箱" min-width="220" />
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-switch :model-value="row.status === 'active'" :loading="saving===row.id" inline-prompt active-text="正常" inactive-text="停用" @change="changeStatus(row, $event)" /></template></el-table-column>
|
||||
<el-table-column prop="created_at" label="注册时间" width="180"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column><el-table-column prop="updated_at" label="更新时间" width="180"><template #default="{ row }">{{ formatTime(row.updated_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="110"><template #default="{ row }"><el-button link type="primary" @click="openGenerations(row)">生成记录</el-button></template></el-table-column>
|
||||
</el-table></section>
|
||||
</main></template></BasicLayout>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<main class="chorus-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>终端用户</h1><p>创建和维护受控 Portal 用户;管理员账号在系统管理中单独维护。</p></div>
|
||||
<el-button type="primary" @click="openCreate"><i class="ri-user-add-line" /> 新增终端用户</el-button>
|
||||
</header>
|
||||
<div class="toolbar">
|
||||
<el-input v-model.trim="keyword" clearable placeholder="用户编号 / 账号 / 昵称 / 邮箱" style="width: 320px" aria-label="筛选终端用户" />
|
||||
<el-select v-model="status" clearable placeholder="全部状态" style="width: 140px" aria-label="筛选状态">
|
||||
<el-option label="正常" value="active" /><el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
<el-button @click="reset"><i class="ri-refresh-line" /> 重置</el-button>
|
||||
<span class="spacer" /><span class="muted">共 {{ filtered.length }} 条</span>
|
||||
</div>
|
||||
<section class="table-panel" aria-label="终端用户列表">
|
||||
<el-table v-loading="loading" :data="filtered" border empty-text="暂无终端用户">
|
||||
<el-table-column prop="id" label="用户编号" width="110"><template #default="{ row }"><span class="mono">#{{ row.id }}</span></template></el-table-column>
|
||||
<el-table-column prop="username" label="账号" min-width="150"><template #default="{ row }"><span class="mono">{{ row.username }}</span></template></el-table-column>
|
||||
<el-table-column prop="display_name" label="昵称" min-width="150" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="220" />
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-switch :model-value="row.status === 'active'" :loading="saving===row.id" inline-prompt active-text="正常" inactive-text="停用" @change="changeStatus(row, $event)" /></template></el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="180"><template #default="{ row }">{{ formatTime(row.created_at) }}</template></el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180"><template #default="{ row }">{{ formatTime(row.updated_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="180"><template #default="{ row }">
|
||||
<el-button link type="primary" @click="openGenerations(row)">生成记录</el-button>
|
||||
<el-button link type="primary" @click="openPasswordReset(row)">重置密码</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<el-dialog v-model="createOpen" title="新增终端用户" width="560px" :close-on-click-modal="false" @closed="clearCreate">
|
||||
<el-form ref="createForm" :model="form" :rules="createRules" label-width="100px">
|
||||
<el-form-item label="账号" prop="username"><el-input v-model.trim="form.username" maxlength="64" autocomplete="off" class="mono" /></el-form-item>
|
||||
<el-form-item label="昵称" prop="display_name"><el-input v-model.trim="form.display_name" maxlength="120" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model.trim="form.email" maxlength="320" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password maxlength="1024" autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label="状态" prop="status"><el-radio-group v-model="form.status"><el-radio-button value="active">正常</el-radio-button><el-radio-button value="disabled">停用</el-radio-button></el-radio-group></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="createOpen=false">取消</el-button><el-button type="primary" :loading="creating" @click="createUser">创建</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="passwordOpen" title="重置终端用户密码" width="520px" :close-on-click-modal="false" @closed="clearPasswordReset">
|
||||
<p class="dialog-help">为 <strong>{{ selectedUser && selectedUser.username }}</strong> 设置新密码。保存后旧密码立即失效。</p>
|
||||
<el-form ref="passwordForm" :model="passwordForm" :rules="passwordRules" label-width="90px">
|
||||
<el-form-item label="新密码" prop="password"><el-input v-model="passwordForm.password" type="password" show-password maxlength="1024" autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm"><el-input v-model="passwordForm.confirm" type="password" show-password maxlength="1024" autocomplete="new-password" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" :loading="resettingPassword" @click="resetPassword">保存新密码</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listPortalUsers, updatePortalUserStatus } from '@/api/chorus'
|
||||
import { createPortalUser, listPortalUsers, resetPortalUserPassword, updatePortalUserStatus } from '@/api/chorus'
|
||||
import { contains, dataOf, formatTime } from '../shared'
|
||||
|
||||
const emptyUser = () => ({ username: '', display_name: '', email: '', password: '', status: 'active' })
|
||||
const emptyPassword = () => ({ password: '', confirm: '' })
|
||||
|
||||
export default {
|
||||
name: 'ChorusUsers',
|
||||
data() { return { loading: false, saving: null, rows: [], keyword: '', status: '' } },
|
||||
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.username, row.display_name, row.email].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) } },
|
||||
data() {
|
||||
const validatePasswordConfirm = (_rule, value, done) => value === this.passwordForm.password ? done() : done(new Error('两次输入的密码不一致'))
|
||||
return {
|
||||
loading: false, saving: null, creating: false, resettingPassword: false,
|
||||
rows: [], keyword: '', status: '', createOpen: false, passwordOpen: false,
|
||||
selectedUser: null, form: emptyUser(), passwordForm: emptyPassword(),
|
||||
createRules: {
|
||||
username: [{ required: true, pattern: /^[a-z0-9][a-z0-9._-]{2,63}$/, message: '输入 3 至 64 位小写字母、数字、点、下划线或连字符', trigger: 'blur' }],
|
||||
display_name: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||
email: [{ required: true, type: 'email', message: '请输入有效邮箱', trigger: 'blur' }],
|
||||
password: [{ required: true, min: 6, max: 1024, message: '密码长度为 6 至 1024 个字符', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
},
|
||||
passwordRules: {
|
||||
password: [{ required: true, min: 6, max: 1024, message: '密码长度为 6 至 1024 个字符', trigger: 'blur' }],
|
||||
confirm: [{ required: true, validator: validatePasswordConfirm, trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filtered() {
|
||||
return this.rows.filter(row => (!this.keyword || [row.id, row.username, row.display_name, row.email].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status))
|
||||
}
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
formatTime,
|
||||
async load() { this.loading = true; try { this.rows = dataOf(await listPortalUsers()) } finally { this.loading = false } },
|
||||
reset() { this.keyword = ''; this.status = '' },
|
||||
async changeStatus(row, enabled) { const next = enabled ? 'active' : 'disabled'; try { await this.$confirm(`确认将用户 #${row.id} 设为${enabled ? '正常' : '停用'}?`, '更新用户状态', { type: 'warning' }) } catch (_) { return } this.saving = row.id; try { await updatePortalUserStatus(row.id, next); row.status = next; this.$message.success('用户状态已更新') } finally { this.saving = null } },
|
||||
openCreate() { this.form = emptyUser(); this.createOpen = true },
|
||||
clearCreate() { this.form = emptyUser(); this.$refs.createForm && this.$refs.createForm.clearValidate() },
|
||||
async createUser() {
|
||||
const valid = await this.$refs.createForm.validate().catch(() => false); if (!valid) return
|
||||
this.creating = true
|
||||
try { await createPortalUser({ ...this.form }); this.$message.success('终端用户已创建'); this.createOpen = false; await this.load() } finally { this.creating = false }
|
||||
},
|
||||
async changeStatus(row, enabled) {
|
||||
const next = enabled ? 'active' : 'disabled'
|
||||
try { await this.$confirm(`确认将用户 #${row.id} 设为${enabled ? '正常' : '停用'}?`, '更新用户状态', { type: 'warning' }) } catch (_) { return }
|
||||
this.saving = row.id
|
||||
try { await updatePortalUserStatus(row.id, next); row.status = next; this.$message.success('用户状态已更新') } finally { this.saving = null }
|
||||
},
|
||||
openPasswordReset(row) { this.selectedUser = row; this.passwordForm = emptyPassword(); this.passwordOpen = true },
|
||||
clearPasswordReset() { this.selectedUser = null; this.passwordForm = emptyPassword(); this.$refs.passwordForm && this.$refs.passwordForm.clearValidate() },
|
||||
async resetPassword() {
|
||||
const valid = await this.$refs.passwordForm.validate().catch(() => false); if (!valid) return
|
||||
this.resettingPassword = true
|
||||
try { await resetPortalUserPassword(this.selectedUser.id, this.passwordForm.password); this.$message.success('密码已重置'); this.passwordOpen = false } finally { this.resettingPassword = false }
|
||||
},
|
||||
openGenerations(row) { this.$router.push({ path: '/chorus/generations', query: { user: row.id }}) }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>@use '../shared.scss';</style>
|
||||
|
||||
@@ -12,4 +12,13 @@ describe('portal user account column', () => {
|
||||
expect(source).toContain('[row.id, row.username, row.display_name, row.email]')
|
||||
expect(source).toContain('prop="email" label="邮箱"')
|
||||
})
|
||||
|
||||
test('supports controlled creation and password reset without public registration copy', () => {
|
||||
expect(source).toContain('新增终端用户')
|
||||
expect(source).toContain('createPortalUser')
|
||||
expect(source).toContain('resetPortalUserPassword')
|
||||
expect(source).toContain('autocomplete="new-password"')
|
||||
expect(source).toContain('label="创建时间"')
|
||||
expect(source).not.toContain('label="注册时间"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,10 @@ type FieldError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type UserIdentityConflictError struct{ Field string }
|
||||
|
||||
func (e UserIdentityConflictError) Error() string { return "portal user identity already exists" }
|
||||
|
||||
func (e FieldError) Error() string {
|
||||
return fmt.Sprintf("invalid %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
@@ -126,9 +126,15 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc
|
||||
r.GET("/users", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
|
||||
return service.Users(c.Request.Context())
|
||||
}))
|
||||
r.POST("/users", withBody[UserCreateInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, input UserCreateInput) (any, error) {
|
||||
return service.CreateUser(c.Request.Context(), actor, requestID, input)
|
||||
}))
|
||||
r.PUT("/users/:id", withIDBody[UserStatusInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64, input UserStatusInput) (any, error) {
|
||||
return service.UpdateUserStatus(c.Request.Context(), actor, requestID, id, input)
|
||||
}))
|
||||
r.PUT("/users/:id/password", withIDBody[UserPasswordInput](factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64, input UserPasswordInput) (any, error) {
|
||||
return service.ResetUserPassword(c.Request.Context(), actor, requestID, id, input)
|
||||
}))
|
||||
r.GET("/generations", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
|
||||
return service.Generations(c.Request.Context())
|
||||
}))
|
||||
@@ -382,11 +388,15 @@ func respond(c *gin.Context, data any, err error) {
|
||||
status, code, message := http.StatusInternalServerError, "internal_error", "request could not be completed"
|
||||
response := gin.H{"code": status, "error": code, "msg": message}
|
||||
var field FieldError
|
||||
var userConflict UserIdentityConflictError
|
||||
var cooldown CooldownError
|
||||
switch {
|
||||
case errors.As(err, &field):
|
||||
status, code, message = http.StatusUnprocessableEntity, "validation_failed", "request fields are invalid"
|
||||
response["field"] = field.Field
|
||||
case errors.As(err, &userConflict):
|
||||
status, code, message = http.StatusConflict, "user_identity_conflict", "username or email is already in use"
|
||||
response["field"] = userConflict.Field
|
||||
case errors.Is(err, ErrNotFound):
|
||||
status, code, message = http.StatusNotFound, "not_found", "resource was not found"
|
||||
case errors.Is(err, ErrConflict):
|
||||
|
||||
@@ -43,6 +43,10 @@ func TestChorusRoutesRequireAuthenticationAuthorizationAndHaveNoDeleteEndpoint(t
|
||||
{name: "generation input not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/inputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "generation output not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "generation thumbnail not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1/thumbnail", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "user create unauthenticated", method: http.MethodPost, path: "/api/v1/chorus/users", want: http.StatusUnauthorized},
|
||||
{name: "user create not authorized", method: http.MethodPost, path: "/api/v1/chorus/users", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "password reset unauthenticated", method: http.MethodPut, path: "/api/v1/chorus/users/1/password", want: http.StatusUnauthorized},
|
||||
{name: "password reset not authorized", method: http.MethodPut, path: "/api/v1/chorus/users/1/password", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "delete is not exposed", method: http.MethodDelete, path: "/api/v1/chorus/providers/1", headers: map[string]string{"Authorization": "Bearer test", "X-Chorus-Role": "operator"}, want: http.StatusNotFound},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
+115
-12
@@ -7,14 +7,19 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/provider"
|
||||
corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage"
|
||||
passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -220,17 +225,20 @@ type auditRow struct {
|
||||
func (auditRow) TableName() string { return "admin_audit_events" }
|
||||
|
||||
type portalUserRow struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
DisplayName string `gorm:"column:display_name"`
|
||||
Status string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
ID uint64 `gorm:"column:id;primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
PasswordHash string `gorm:"column:password_hash"`
|
||||
DisplayName string `gorm:"column:display_name"`
|
||||
Status string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (portalUserRow) TableName() string { return "users" }
|
||||
|
||||
var portalUserPublicColumns = []string{"id", "username", "email", "display_name", "status", "created_at", "updated_at"}
|
||||
|
||||
type generationRow struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey"`
|
||||
UserID uint64 `gorm:"column:user_id"`
|
||||
@@ -693,7 +701,7 @@ func (s *Service) RoutePool(ctx context.Context, id uint64) (RoutePoolView, erro
|
||||
|
||||
func (s *Service) Users(ctx context.Context) ([]UserView, error) {
|
||||
var rows []portalUserRow
|
||||
if err := s.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", err)
|
||||
}
|
||||
items := make([]UserView, 0, len(rows))
|
||||
@@ -703,6 +711,79 @@ func (s *Service) Users(ctx context.Context) ([]UserView, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateUser(ctx context.Context, actor uint64, requestID string, input UserCreateInput) (UserView, error) {
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if err := validateUserCreate(input); err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
passwordHash, err := passwordpkg.Encode(input.Password)
|
||||
if err != nil {
|
||||
return UserView{}, FieldError{Field: "password", Message: "must be 6 to 1024 characters"}
|
||||
}
|
||||
row := portalUserRow{
|
||||
Username: input.Username, Email: input.Email, PasswordHash: passwordHash,
|
||||
DisplayName: input.DisplayName, Status: input.Status,
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&portalUserRow{}).Where("username = ?", input.Username).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("check portal username: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return UserIdentityConflictError{Field: "username"}
|
||||
}
|
||||
if err := tx.Model(&portalUserRow{}).Where("email = ?", input.Email).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("check portal user email: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return UserIdentityConflictError{Field: "email"}
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
|
||||
return UserIdentityConflictError{Field: "username_or_email"}
|
||||
}
|
||||
return fmt.Errorf("create portal user: %w", err)
|
||||
}
|
||||
return s.audit(tx, actor, requestID, "user.create", "user", row.ID, "succeeded", map[string]any{
|
||||
"username": row.Username, "status": row.Status,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
return portalUserView(row), nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetUserPassword(ctx context.Context, actor uint64, requestID string, id uint64, input UserPasswordInput) (UserView, error) {
|
||||
passwordHash, err := passwordpkg.Encode(input.Password)
|
||||
if err != nil {
|
||||
return UserView{}, FieldError{Field: "password", Message: "must be 6 to 1024 characters"}
|
||||
}
|
||||
var row portalUserRow
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Select(portalUserPublicColumns).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
return translateNotFound(err)
|
||||
}
|
||||
if err := tx.Model(&portalUserRow{}).Where("id = ?", id).Update("password_hash", passwordHash).Error; err != nil {
|
||||
return fmt.Errorf("reset portal user password: %w", err)
|
||||
}
|
||||
return s.audit(tx, actor, requestID, "user.password.reset", "user", id, "succeeded", map[string]any{
|
||||
"username": row.Username,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&row, id).Error; err != nil {
|
||||
return UserView{}, translateNotFound(err)
|
||||
}
|
||||
return portalUserView(row), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID string, id uint64, input UserStatusInput) (UserView, error) {
|
||||
input.Status = strings.TrimSpace(input.Status)
|
||||
if input.Status != "active" && input.Status != "disabled" {
|
||||
@@ -710,7 +791,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID
|
||||
}
|
||||
var row portalUserRow
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
if err := tx.Select(portalUserPublicColumns).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
return translateNotFound(err)
|
||||
}
|
||||
if err := tx.Model(&portalUserRow{}).Where("id = ?", id).Update("status", input.Status).Error; err != nil {
|
||||
@@ -722,7 +803,7 @@ func (s *Service) UpdateUserStatus(ctx context.Context, actor uint64, requestID
|
||||
if err != nil {
|
||||
return UserView{}, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&row, id).Error; err != nil {
|
||||
return UserView{}, translateNotFound(err)
|
||||
}
|
||||
return portalUserView(row), nil
|
||||
@@ -762,7 +843,7 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) {
|
||||
users := make(map[uint64]portalUserRow, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var userRows []portalUserRow
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", userIDs).Find(&userRows).Error; err != nil {
|
||||
if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).Where("id IN ?", userIDs).Find(&userRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list generation users: %w", err)
|
||||
}
|
||||
for _, row := range userRows {
|
||||
@@ -866,7 +947,7 @@ func (s *Service) Generation(ctx context.Context, id uint64) (GenerationDetailVi
|
||||
|
||||
func (s *Service) generationView(ctx context.Context, row generationRow) (GenerationView, error) {
|
||||
var user portalUserRow
|
||||
if err := s.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := s.db.WithContext(ctx).Select(portalUserPublicColumns).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return GenerationView{}, fmt.Errorf("get generation user: %w", err)
|
||||
}
|
||||
attempts := parseGenerationAttempts(row.Attempts)
|
||||
@@ -1388,6 +1469,28 @@ func validateProvider(input ProviderInput, creating bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var portalUsernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
|
||||
|
||||
func validateUserCreate(input UserCreateInput) error {
|
||||
if !portalUsernamePattern.MatchString(input.Username) {
|
||||
return FieldError{Field: "username", Message: "must be 3 to 64 lowercase letters, digits, dots, underscores, or hyphens and start with a letter or digit"}
|
||||
}
|
||||
parsedEmail, err := mail.ParseAddress(input.Email)
|
||||
if err != nil || parsedEmail.Address != input.Email || len(input.Email) > 320 {
|
||||
return FieldError{Field: "email", Message: "must be a valid email address up to 320 characters"}
|
||||
}
|
||||
if utf8.RuneCountInString(input.DisplayName) < 1 || utf8.RuneCountInString(input.DisplayName) > 120 {
|
||||
return FieldError{Field: "display_name", Message: "must be 1 to 120 characters"}
|
||||
}
|
||||
if input.Status != "active" && input.Status != "disabled" {
|
||||
return FieldError{Field: "status", Message: "must be active or disabled"}
|
||||
}
|
||||
if len(input.Password) < 6 || len(input.Password) > 1024 {
|
||||
return FieldError{Field: "password", Message: "must be 6 to 1024 characters"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBaseURL(raw string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
@@ -123,15 +124,49 @@ func TestAdminServiceMySQL(t *testing.T) {
|
||||
}
|
||||
userEmail := "user-" + suffix + "@chorus.invalid"
|
||||
userUsername := "user-" + suffix
|
||||
if err := db.Exec("INSERT INTO users (username, email, password_hash, display_name, status) VALUES (?, ?, 'synthetic', 'Admin Test User', 'active')", userUsername, userEmail).Error; err != nil {
|
||||
t.Fatalf("create user status fixture: %v", err)
|
||||
initialPassword := "initial-" + suffix
|
||||
createdUser, err := service.CreateUser(ctx, 7, "user-create-"+suffix, UserCreateInput{
|
||||
Username: userUsername, Email: userEmail, DisplayName: "Admin Test User",
|
||||
Password: initialPassword, Status: "active",
|
||||
})
|
||||
if err != nil || createdUser.Username != userUsername || createdUser.Status != "active" {
|
||||
t.Fatalf("created user=%#v error=%v", createdUser, err)
|
||||
}
|
||||
defer db.Exec("DELETE FROM users WHERE email = ?", userEmail)
|
||||
var userID uint64
|
||||
if err := db.Raw("SELECT id FROM users WHERE email = ?", userEmail).Scan(&userID).Error; err != nil || userID == 0 {
|
||||
t.Fatalf("user fixture id=%d error=%v", userID, err)
|
||||
if _, err := service.CreateUser(ctx, 7, "user-duplicate-"+suffix, UserCreateInput{
|
||||
Username: userUsername, Email: "other-" + userEmail, DisplayName: "Duplicate User",
|
||||
Password: initialPassword, Status: "active",
|
||||
}); !isUserIdentityConflict(err) {
|
||||
t.Fatalf("duplicate user error = %v, want identity conflict", err)
|
||||
}
|
||||
updatedUser, err := service.UpdateUserStatus(ctx, 7, "user-status-"+suffix, userID, UserStatusInput{Status: "disabled"})
|
||||
var storedHash string
|
||||
if err := db.Raw("SELECT password_hash FROM users WHERE id = ?", createdUser.ID).Scan(&storedHash).Error; err != nil {
|
||||
t.Fatalf("read created user password hash: %v", err)
|
||||
}
|
||||
if !passwordpkg.Verify(storedHash, initialPassword) || strings.Contains(storedHash, initialPassword) {
|
||||
t.Fatal("created portal user password is not stored in the compatible encoded form")
|
||||
}
|
||||
newPassword := "replacement-" + suffix
|
||||
resetUser, err := service.ResetUserPassword(ctx, 7, "user-password-"+suffix, createdUser.ID, UserPasswordInput{Password: newPassword})
|
||||
if err != nil || resetUser.ID != createdUser.ID {
|
||||
t.Fatalf("reset user=%#v error=%v", resetUser, err)
|
||||
}
|
||||
if err := db.Raw("SELECT password_hash FROM users WHERE id = ?", createdUser.ID).Scan(&storedHash).Error; err != nil {
|
||||
t.Fatalf("read reset user password hash: %v", err)
|
||||
}
|
||||
if passwordpkg.Verify(storedHash, initialPassword) || !passwordpkg.Verify(storedHash, newPassword) {
|
||||
t.Fatal("password reset did not invalidate the old password and activate the new password")
|
||||
}
|
||||
var userAudits []string
|
||||
if err := db.Raw("SELECT CAST(summary AS CHAR) FROM admin_audit_events WHERE target_type = 'user' AND target_id = ?", fmt.Sprint(createdUser.ID)).Scan(&userAudits).Error; err != nil {
|
||||
t.Fatalf("read user audit events: %v", err)
|
||||
}
|
||||
for _, summary := range userAudits {
|
||||
if strings.Contains(summary, initialPassword) || strings.Contains(summary, newPassword) || strings.Contains(summary, storedHash) {
|
||||
t.Fatalf("user audit contains protected password data: %s", summary)
|
||||
}
|
||||
}
|
||||
updatedUser, err := service.UpdateUserStatus(ctx, 7, "user-status-"+suffix, createdUser.ID, UserStatusInput{Status: "disabled"})
|
||||
if err != nil || updatedUser.Status != "disabled" || updatedUser.Username != userUsername {
|
||||
t.Fatalf("updated user=%#v error=%v", updatedUser, err)
|
||||
}
|
||||
@@ -517,11 +552,16 @@ func deleteAdminFixture(t *testing.T, db *gorm.DB, suffix string) {
|
||||
if err := db.Where("id IN ?", providerIDs).Delete(&providerRow{}).Error; err != nil {
|
||||
t.Errorf("delete providers: %v", err)
|
||||
}
|
||||
if err := db.Where("target_type IN ('provider', 'provider_model', 'route_pool') AND request_id LIKE ?", "%"+suffix+"%").Delete(&auditRow{}).Error; err != nil {
|
||||
if err := db.Where("target_type IN ('provider', 'provider_model', 'route_pool', 'user') AND request_id LIKE ?", "%"+suffix+"%").Delete(&auditRow{}).Error; err != nil {
|
||||
t.Errorf("delete audit events: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isUserIdentityConflict(err error) bool {
|
||||
var conflict UserIdentityConflictError
|
||||
return errors.As(err, &conflict)
|
||||
}
|
||||
|
||||
func registerActiveRouteRestore(t *testing.T, db *gorm.DB, capability model.Capability) {
|
||||
t.Helper()
|
||||
var previous activeRouteRow
|
||||
|
||||
@@ -2,6 +2,7 @@ package chorus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
@@ -16,6 +17,32 @@ func TestPortalUserViewUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserCreate(t *testing.T) {
|
||||
valid := UserCreateInput{Username: "portal-user", Email: "portal@example.invalid", DisplayName: "Portal User", Password: "secret6", Status: "active"}
|
||||
if err := validateUserCreate(valid); err != nil {
|
||||
t.Fatalf("valid user rejected: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input UserCreateInput
|
||||
field string
|
||||
}{
|
||||
{name: "uppercase username", input: UserCreateInput{Username: "Portal-user", Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "username"},
|
||||
{name: "email-shaped username", input: UserCreateInput{Username: "a@b", Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "username"},
|
||||
{name: "invalid email", input: UserCreateInput{Username: valid.Username, Email: "invalid", DisplayName: valid.DisplayName, Password: valid.Password, Status: valid.Status}, field: "email"},
|
||||
{name: "empty display name", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: "", Password: valid.Password, Status: valid.Status}, field: "display_name"},
|
||||
{name: "short password", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: valid.DisplayName, Password: "12345", Status: valid.Status}, field: "password"},
|
||||
{name: "invalid status", input: UserCreateInput{Username: valid.Username, Email: valid.Email, DisplayName: valid.DisplayName, Password: valid.Password, Status: "pending"}, field: "status"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var field FieldError
|
||||
if err := validateUserCreate(test.input); !errors.As(err, &field) || field.Field != test.field {
|
||||
t.Fatalf("error = %#v, want field %s", err, test.field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationAttemptEnrichmentAndMalformedFallback(t *testing.T) {
|
||||
attempts := parseGenerationAttempts(json.RawMessage(`[{"type":"lease"},{"type":"provider","provider_model_id":4,"provider_ordinal":1}]`))
|
||||
items := enrichGenerationAttempts(attempts, map[uint64]providerModelRow{4: {ID: 4, ProviderID: 2, Name: "图像模型"}}, map[uint64]providerRow{2: {ID: 2, Name: "图像服务"}})
|
||||
|
||||
@@ -163,6 +163,18 @@ type UserStatusInput struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UserCreateInput struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UserPasswordInput struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: cc8d6fe5bd31eb22aeca2b7e4b6a13077589a0a7
|
||||
synchronized_at: 2026-08-27T01:57:40Z
|
||||
wiki_revision: 0ae0f617f353daa24a43ea3c48cd188599b6f5b9
|
||||
synchronized_at: 2026-08-28T13:26:18Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -499,3 +499,13 @@ Admin 生成记录列表仍只返回任务摘要。管理员点击“查看”
|
||||
|
||||
请求沿用 go-admin JWT 与 Casbin。服务先核对 generation 与 input/output 的数据库关系,再核对存储对象的 owner、generation 和 MIME 元数据;响应不包含 `storage_key`、缩略图 key 或本地路径。Admin 只注入 `storage.Reader` 的 `Open` 能力,不持有 `Put` 或 `Delete`。前端通过带 Authorization header 的 Axios Blob 请求读取图片,并在关闭详情时释放对象 URL,不把 Token 拼入媒体 URL。
|
||||
<!-- issue-72-admin-generation-media:end -->
|
||||
|
||||
<!-- issue-73:start -->
|
||||
## 管理员创建受控终端用户(#73)
|
||||
|
||||
- Portal 继续只注册账号密码登录,不提供公开注册、找回密码或邮件验证入口。
|
||||
- Admin 在既有 JWT 与 Casbin 边界内提供 `POST /api/v1/chorus/users` 和 `PUT /api/v1/chorus/users/:id/password`;`sys_user` 与 `users` 仍然分表且不复用凭据。
|
||||
- 创建用户统一规范化账号和邮箱,复用 `internal/platform/password` 的 `bcrypt:v1` 编码;密码重置只替换哈希,响应、错误、日志和审计不返回密码或哈希。
|
||||
- 终端用户列表和生成记录关联查询显式选择公开字段,不读取 `password_hash`。创建、启停和密码重置写入脱敏的 `admin_audit_events`。
|
||||
- `000011_admin_portal_user_management` 只增加两条 Admin API、菜单关联和 `chorus_operator` Casbin 权限,down 不删除或修改任何终端用户数据。
|
||||
<!-- issue-73:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 3ea4bac9925a081f33c2745438deac7e218cb309
|
||||
synchronized_at: 2026-08-27T01:57:53Z
|
||||
wiki_revision: 3c04a5144ee4280aa9f521870713361938b35d72
|
||||
synchronized_at: 2026-08-28T13:26:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -238,3 +238,13 @@ synchronized_at: 2026-08-27T01:57:53Z
|
||||
- 媒体端点只读,不提供编辑、删除、重新生成、批量下载或公开分享。
|
||||
- Admin 列表不批量返回输入/输出,只有显式打开详情后才加载。
|
||||
<!-- issue-72-admin-media-rules:end -->
|
||||
|
||||
<!-- issue-73:start -->
|
||||
## 受控终端用户创建与密码重置(#73)
|
||||
|
||||
- Portal 仍不开放自助注册;终端用户只能由获授权管理员创建。
|
||||
- 创建字段为账号、昵称、邮箱、初始密码和状态。账号、邮箱规范化后分别唯一;状态只允许 `active` 或 `disabled`。
|
||||
- 终端用户密码继续使用 6 至 1024 字符规则和 `bcrypt:v1` 编码。管理员只能设置新密码,不能读取旧密码、当前密码或密码哈希。
|
||||
- 停用用户不能登录 Portal;密码重置后旧密码立即失效。
|
||||
- 创建、启停和密码重置必须审计,但任何响应、错误、日志、审计摘要或管理列表都不得包含密码或密码哈希。
|
||||
<!-- issue-73:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 4a087f2f6a5fbdb7a93380ef8df8ea722fed2f71
|
||||
synchronized_at: 2026-08-27T01:58:01Z
|
||||
wiki_revision: 7e5263ea3ca25119464e10b1afcda12e9582229c
|
||||
synchronized_at: 2026-08-28T13:26:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -620,3 +620,23 @@ pnpm -C admin-ui build:prod
|
||||
|
||||
migration 10 必须在精确命名的可丢弃 MySQL 8 测试库执行 up/down/up。测试应核对四个 API、菜单关联和 `chorus_operator` Casbin 规则,并确认 down 只移除本迁移登记的权限数据。浏览器验收使用现有管理员会话和已有记录,不调用 Provider;覆盖详情按需打开、多图、文本、空结果、对象缺失、错误状态和窄屏,检查媒体请求使用 Authorization header 且 URL 不包含 Token 或存储键。
|
||||
<!-- issue-72-admin-media-verification:end -->
|
||||
|
||||
<!-- issue-73:start -->
|
||||
## 管理员创建终端用户(#73)
|
||||
|
||||
1. 先把数据库迁移到 version 11;生产仍禁止 AutoMigrate。
|
||||
2. 登录 Admin,进入“用户与访问 → 终端用户”,点击“新增终端用户”,填写账号、昵称、邮箱、初始密码和状态。
|
||||
3. 创建成功后用户立即出现在列表;active 用户可用账号和初始密码登录 Portal,disabled 用户不能登录。
|
||||
4. 行操作“重置密码”只接受并确认新密码;保存后旧密码失效。
|
||||
|
||||
最小验证命令:
|
||||
|
||||
```powershell
|
||||
go test ./migrations ./portal/auth ./portal/handler
|
||||
go -C admin test ./app/chorus
|
||||
corepack pnpm@9.15.1 --dir admin-ui exec jest tests/unit/chorus/users-account.spec.js --runInBand
|
||||
corepack pnpm@9.15.1 --dir admin-ui build:prod
|
||||
```
|
||||
|
||||
migration 的 up/down/up 必须只在明确指定的可丢弃 `chorus_test` 数据库执行;version 11 的 down 只撤销权限配置,不删除已经创建的用户。
|
||||
<!-- issue-73:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements-Overview
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Product-Requirements-Overview.-
|
||||
wiki_revision: 6c7e3b2aee8c0f675013cf60020243cadb566e77
|
||||
synchronized_at: 2026-08-26T12:38:17Z
|
||||
wiki_revision: 5a9e186e2c9af9b68f3934f2c19889f07326cbfe
|
||||
synchronized_at: 2026-08-28T13:27:49Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求总览
|
||||
@@ -318,3 +318,9 @@ MVP-2 父工单 #35 仍等待用户单独确认后关闭;本节只记录 #47
|
||||
- Admin 时间线把 lease、本地限流等标记为系统事件,不占 Provider 尝试序号;Provider 尝试显示持久化序号、Provider/模型名称、单次耗时和状态。
|
||||
- 旧记录的缺失、畸形或逆序时间采用安全兜底,不显示负耗时,也不导致页面异常。耗时只从既有时间戳和 `latency_ms` 派生,不持久化重复字段。
|
||||
<!-- issue-65:end -->
|
||||
|
||||
<!-- issue-73:start -->
|
||||
## 管理员创建受控终端用户(#73)
|
||||
|
||||
为减少日常通过 seed 或数据库建号的操作,管理端增加受控终端用户创建和密码重置能力;Portal 继续不开放注册、找回密码或邮件验证。该能力复用现有终端用户页和对话框规范,保持管理员与终端用户分表,创建与密码重置受 JWT/Casbin 保护并写脱敏审计。工单 [#73](https://git.ilapage.cn/OPC/chorus/issues/73) 当前处于待验收。
|
||||
<!-- issue-73:end -->
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
DELETE casbin
|
||||
FROM sys_casbin_rule casbin
|
||||
JOIN sys_api api
|
||||
ON casbin.ptype = 'p'
|
||||
AND casbin.v0 = 'chorus_operator'
|
||||
AND casbin.v1 = api.path
|
||||
AND casbin.v2 = api.action
|
||||
WHERE api.handle IN ('chorus.users.create', 'chorus.users.password.reset');
|
||||
|
||||
DELETE menu_api
|
||||
FROM sys_menu_api_rule menu_api
|
||||
JOIN sys_api api ON api.id = menu_api.sys_api_id
|
||||
JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id
|
||||
WHERE menu.path = '/chorus/users'
|
||||
AND api.handle IN ('chorus.users.create', 'chorus.users.password.reset');
|
||||
|
||||
DELETE FROM sys_api
|
||||
WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset');
|
||||
@@ -0,0 +1,17 @@
|
||||
INSERT INTO sys_api (handle, title, path, type, action)
|
||||
VALUES
|
||||
('chorus.users.create', 'Create portal user', '/api/v1/chorus/users', 'BUS', 'POST'),
|
||||
('chorus.users.password.reset', 'Reset portal user password', '/api/v1/chorus/users/:id/password', 'BUS', 'PUT')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title), type = VALUES(type), deleted_at = NULL;
|
||||
|
||||
INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id)
|
||||
SELECT menu.menu_id, api.id
|
||||
FROM sys_menu menu
|
||||
JOIN sys_api api ON api.handle IN ('chorus.users.create', 'chorus.users.password.reset')
|
||||
WHERE menu.path = '/chorus/users';
|
||||
|
||||
INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||
SELECT 'p', 'chorus_operator', path, action, '', '', ''
|
||||
FROM sys_api
|
||||
WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset');
|
||||
@@ -32,6 +32,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
|
||||
"000008_portal_username_login.up.sql",
|
||||
"000009_admin_grouped_navigation.up.sql",
|
||||
"000010_admin_generation_media.up.sql",
|
||||
"000011_admin_portal_user_management.up.sql",
|
||||
}
|
||||
slices.Sort(upFiles)
|
||||
if !slices.Equal(upFiles, wantFiles) {
|
||||
@@ -63,6 +64,34 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPortalUserManagementMigrationContracts(t *testing.T) {
|
||||
up, err := os.ReadFile("000011_admin_portal_user_management.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down, err := os.ReadFile("000011_admin_portal_user_management.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"chorus.users.create", "chorus.users.password.reset", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/users",
|
||||
} {
|
||||
if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) {
|
||||
t.Errorf("portal user management migration is missing reversible contract %s", required)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{"/api/v1/chorus/users", "/api/v1/chorus/users/:id/password"} {
|
||||
if !strings.Contains(string(up), route) {
|
||||
t.Errorf("portal user management migration is missing route %s", route)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"INSERT INTO users", "DELETE FROM users", "password_hash", "AutoMigrate"} {
|
||||
if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) {
|
||||
t.Errorf("portal user permission migration must not touch %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGenerationMediaMigrationContracts(t *testing.T) {
|
||||
up, err := os.ReadFile("000010_admin_generation_media.up.sql")
|
||||
if err != nil {
|
||||
|
||||
@@ -157,7 +157,13 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 LIKE '/api/v1/chorus/generations/%' AND v2 = 'GET'`, 4)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 IN ('/api/v1/chorus/users', '/api/v1/chorus/users/:id/password') AND v2 IN ('POST', 'PUT')`, 2)
|
||||
|
||||
runMigrate("down", "1")
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 0)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
||||
assertGroupedAdminNavigation(t, ctx, db)
|
||||
runMigrate("down", "1")
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 0)
|
||||
assertGroupedAdminNavigation(t, ctx, db)
|
||||
@@ -205,6 +211,7 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
|
||||
assertGroupedAdminNavigation(t, ctx, db)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.users.create', 'chorus.users.password.reset')`, 2)
|
||||
}
|
||||
|
||||
func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {
|
||||
|
||||
Reference in New Issue
Block a user