Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15bd000816 | ||
|
|
7f13e595b6 | ||
|
|
116318df74 | ||
|
|
bdb9a5b474 | ||
|
|
a92e4da043 | ||
|
|
bc1a01848d | ||
|
|
0bbdb27f6d | ||
|
|
d0185caa0e | ||
|
|
6257859b83 | ||
|
|
3dd489066c | ||
|
|
40409707cc |
@@ -1,7 +1,9 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -19,6 +21,9 @@ func init() {
|
||||
|
||||
func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := prepareLegacyMediaRouteSchema(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&media.Route{}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -58,3 +63,122 @@ func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type mediaPathConstraint struct {
|
||||
Name string
|
||||
Columns string
|
||||
}
|
||||
|
||||
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
|
||||
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
|
||||
// the runtime-state columns now required by the Route model.
|
||||
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
var tableCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media route table: %w", err)
|
||||
}
|
||||
if tableCount == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
|
||||
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
|
||||
}
|
||||
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
|
||||
var constraints []mediaPathConstraint
|
||||
if err := tx.Raw(`SELECT c.conname AS name,
|
||||
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
|
||||
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.contype = 'u'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM unnest(c.conkey) AS key(attnum)
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
|
||||
WHERE a.attname = 'path'
|
||||
)
|
||||
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
|
||||
return fmt.Errorf("inspect legacy media path constraints: %w", err)
|
||||
}
|
||||
if len(constraints) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(constraints) != 1 || constraints[0].Columns != "path" {
|
||||
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
|
||||
}
|
||||
const expectedName = "uni_sense_media_routes_path"
|
||||
if constraints[0].Name == expectedName {
|
||||
return nil
|
||||
}
|
||||
var conflictingNameCount int64
|
||||
if err := tx.Raw(`SELECT COUNT(*)
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND t.relname = 'sense_media_routes'
|
||||
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
|
||||
return fmt.Errorf("inspect target media path constraint name: %w", err)
|
||||
}
|
||||
if conflictingNameCount != 0 {
|
||||
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
|
||||
}
|
||||
rename := fmt.Sprintf(
|
||||
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
|
||||
quotePostgresIdentifier(constraints[0].Name),
|
||||
quotePostgresIdentifier(expectedName),
|
||||
)
|
||||
if err := tx.Exec(rename).Error; err != nil {
|
||||
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
|
||||
for _, statement := range []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
|
||||
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
|
||||
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
|
||||
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
|
||||
SET "source_ready" = COALESCE("source_ready", false),
|
||||
"failure_count" = COALESCE("failure_count", 0),
|
||||
"last_error_code" = COALESCE("last_error_code", '')
|
||||
WHERE "source_ready" IS NULL
|
||||
OR "failure_count" IS NULL
|
||||
OR "last_error_code" IS NULL`},
|
||||
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
|
||||
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
|
||||
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
|
||||
} {
|
||||
if err := tx.Exec(statement.sql).Error; err != nil {
|
||||
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quotePostgresIdentifier(value string) string {
|
||||
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -21,34 +22,177 @@ func TestMediaMigrationOnPostgres(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
const schema = "sense_media_95_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
|
||||
if err = db.Exec(`INSERT INTO sense_media_routes
|
||||
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
|
||||
VALUES
|
||||
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
|
||||
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const migrationVersion = "2026081419000_media.go"
|
||||
if err = migrateSenseMedia(db, migrationVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
|
||||
t.Fatal("expected path uniqueness violation")
|
||||
}
|
||||
var runtimeState struct {
|
||||
SourceReady bool
|
||||
FailureCount int64
|
||||
LastError string
|
||||
}
|
||||
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
|
||||
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
|
||||
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatalf("repeat compatibility migration: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does nothing when the table is absent", func(t *testing.T) {
|
||||
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates a fresh media table", func(t *testing.T) {
|
||||
resetEmptyMediaMigration(t, db)
|
||||
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 {
|
||||
t.Fatalf("fresh route count=%d", routes)
|
||||
}
|
||||
assertMediaPathUniqueIndex(t, db)
|
||||
})
|
||||
|
||||
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
|
||||
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
|
||||
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
|
||||
t.Fatalf("expected unsupported uniqueness error, got %v", err)
|
||||
}
|
||||
var constraints int64
|
||||
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if constraints != 1 {
|
||||
t.Fatalf("constraint rollback count=%d", constraints)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
|
||||
}
|
||||
|
||||
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var routes, menus, policies, applied int64
|
||||
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||
createRouteTable := `CREATE TABLE sense_media_routes (
|
||||
id text PRIMARY KEY,
|
||||
device_id text NOT NULL,
|
||||
profile_token text NOT NULL,
|
||||
path text NOT NULL,
|
||||
desired text NOT NULL,
|
||||
actual text NOT NULL,
|
||||
readers integer NOT NULL DEFAULT 0,
|
||||
detail text NOT NULL DEFAULT '',
|
||||
version bigint NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
` + pathConstraint + `
|
||||
)`
|
||||
if err := db.Exec(createRouteTable).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||
if err := db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
|
||||
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err := db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var indexes []struct {
|
||||
IndexName string
|
||||
IndexDef string
|
||||
}
|
||||
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename = 'sense_media_routes'
|
||||
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, index := range indexes {
|
||||
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing unique path index: %#v", indexes)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
|
||||
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
|
||||
|
||||
return nil, jwt.ErrMissingLoginValues
|
||||
}
|
||||
if config.ApplicationConfig.Mode != "dev" {
|
||||
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
|
||||
username = loginVals.Username
|
||||
msg = "验证码错误"
|
||||
status = "1"
|
||||
|
||||
return nil, jwt.ErrInvalidVerificationode
|
||||
}
|
||||
}
|
||||
sysUser, role, e := loginVals.GetUser(db)
|
||||
if e == nil {
|
||||
username = loginVals.Username
|
||||
|
||||
return map[string]interface{}{"user": sysUser, "role": role}, nil
|
||||
} else {
|
||||
username = loginVals.Username
|
||||
msg = "登录失败"
|
||||
status = "1"
|
||||
log.Warnf("%s login failed!", loginVals.Username)
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
type Login struct {
|
||||
Username string `form:"UserName" json:"username" binding:"required"`
|
||||
Password string `form:"Password" json:"password" binding:"required"`
|
||||
Code string `form:"Code" json:"code" binding:"required"`
|
||||
UUID string `form:"UUID" json:"uuid" binding:"required"`
|
||||
}
|
||||
|
||||
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var login Login
|
||||
if err := ctx.ShouldBindJSON(&login); err != nil {
|
||||
t.Fatalf("bind credentials-only login: %v", err)
|
||||
}
|
||||
if login.Username != "operator" || login.Password != "valid-password" {
|
||||
t.Fatalf("unexpected login payload: username=%q", login.Username)
|
||||
}
|
||||
|
||||
typeOfLogin := reflect.TypeOf(login)
|
||||
if typeOfLogin.NumField() != 2 {
|
||||
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
|
||||
}
|
||||
for _, removed := range []string{"Code", "UUID"} {
|
||||
if _, ok := typeOfLogin.FieldByName(removed); ok {
|
||||
t.Fatalf("captcha field %s must not be part of the login payload", removed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
|
||||
"handler.Login": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"password",
|
||||
"username",
|
||||
"uuid"
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3789,23 +3789,15 @@
|
||||
"handler.Login": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"password",
|
||||
"username",
|
||||
"uuid"
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"uuid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4345,4 +4337,4 @@
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,19 +685,13 @@ definitions:
|
||||
type: object
|
||||
handler.Login:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
uuid:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- password
|
||||
- username
|
||||
- uuid
|
||||
type: object
|
||||
models.SysApi:
|
||||
properties:
|
||||
|
||||
@@ -102,29 +102,6 @@
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
placeholder="请输入验证码"
|
||||
name="code"
|
||||
type="text"
|
||||
tabindex="3"
|
||||
maxlength="5"
|
||||
autocomplete="off"
|
||||
size="large"
|
||||
:prefix-icon="Key"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
<div class="captcha-wrap" title="点击刷新" @click="getCode">
|
||||
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
|
||||
<div v-else class="captcha-placeholder">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
:loading="loading"
|
||||
type="primary"
|
||||
@@ -142,27 +119,22 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCodeImg } from '@/api/login'
|
||||
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
|
||||
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
return { User, Lock, Key, View, Hide, Monitor, Loading }
|
||||
return { User, Lock, View, Hide, Monitor }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
codeUrl: '',
|
||||
loginForm: {
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
uuid: ''
|
||||
password: ''
|
||||
},
|
||||
loginRules: {
|
||||
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
|
||||
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
|
||||
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
|
||||
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
|
||||
},
|
||||
passwordType: 'password',
|
||||
capsTooltip: false,
|
||||
@@ -185,7 +157,6 @@ export default {
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getCode()
|
||||
this.getSystemSetting()
|
||||
},
|
||||
mounted() {
|
||||
@@ -202,15 +173,6 @@ export default {
|
||||
document.title = ret.sys_app_name
|
||||
})
|
||||
},
|
||||
getCode() {
|
||||
this.codeUrl = ''
|
||||
getCodeImg().then((res) => {
|
||||
if (res !== undefined) {
|
||||
this.codeUrl = res.data
|
||||
this.loginForm.uuid = res.id
|
||||
}
|
||||
})
|
||||
},
|
||||
checkCapslock({ shiftKey, key } = {}) {
|
||||
if (key && key.length === 1) {
|
||||
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
|
||||
@@ -238,7 +200,6 @@ export default {
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false
|
||||
this.getCode()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -562,48 +523,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 验证码 ── */
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-wrap {
|
||||
width: 110px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-placeholder {
|
||||
color: #c1c7d0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ── 登录按钮 ── */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import LoginPage from '@/views/login/index.vue'
|
||||
|
||||
describe('Sense login page', () => {
|
||||
it('uses username and password without a captcha challenge', () => {
|
||||
const state = LoginPage.data()
|
||||
const getSystemSetting = jest.fn()
|
||||
|
||||
LoginPage.created.call({ getSystemSetting })
|
||||
|
||||
expect(Object.keys(state.loginForm)).toEqual(['username', 'password'])
|
||||
expect(Object.keys(state.loginRules)).toEqual(['username', 'password'])
|
||||
expect(LoginPage.methods.getCode).toBeUndefined()
|
||||
expect(getSystemSetting).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
|
||||
synchronized_at: 2026-08-15T01:13:07Z
|
||||
wiki_revision: ee4819a84dd40846bc8fe1865e08590e1cf9cb81
|
||||
synchronized_at: 2026-08-15T09:58:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -146,7 +146,7 @@ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:<端口>/api/v1/bootstrap"
|
||||
Remove-Variable bootstrapToken, bootstrapBody
|
||||
```
|
||||
|
||||
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。生产登录需要先调用验证码接口并提交验证码;自动化集成验证不得通过关闭生产安全约束来冒充生产结果。
|
||||
初始化成功后停止服务,从启动环境中执行 `Remove-Item Env:SENSE_BOOTSTRAP_TOKEN`,再按正常生产方式启动。已有任一用户时初始化接口会拒绝请求。Sense 在 production、test、dev 模式均只提交账号和密码,不显示、不请求也不校验验证码;`/api/v1/captcha` 暂时保留作上游兼容接口,但登录页和登录 API 不依赖它。登录成功、错误密码和未认证拒绝仍必须写入脱敏身份审计,密码继续执行 6–72 字节策略。
|
||||
|
||||
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
|
||||
|
||||
@@ -230,9 +230,38 @@ corepack pnpm@9.15.1 build:prod
|
||||
<!-- bell-runtime:end -->
|
||||
|
||||
<!-- sense-windows-package:start -->
|
||||
## Sense Windows 打包状态
|
||||
## Sense Windows 打包与验证
|
||||
|
||||
旧 Sense Windows 包脚本只属于 `explore` 快照。新的打包命令必须在 GoAdmin 派生骨架和业务迁移完成后由独立工单重新建立、验证和记录。
|
||||
在仓库根目录使用冻结工具链构建:
|
||||
|
||||
```powershell
|
||||
Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe
|
||||
```
|
||||
|
||||
构建脚本严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,执行前端生产构建与 Windows 后端构建,并生成 `Sense\dist\sense-windows-amd64\` 和同名 ZIP。未传 `-MediaMTXPath` 时只生成占位说明,交付前必须另外提供已审核的 Windows amd64 MediaMTX。构建末尾会执行包审计,并清理源码目录的 `Sense/ui/node_modules` 与 `Sense/ui/dist`。
|
||||
|
||||
提交前验证:
|
||||
|
||||
```powershell
|
||||
cd Sense\server
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
go test -race ./app/sense/media ./cmd/api
|
||||
|
||||
cd ..\..
|
||||
Sense\scripts\build\test-package.ps1 -PackageRoot Sense\dist\sense-windows-amd64
|
||||
```
|
||||
|
||||
包内验证从解压目录执行:
|
||||
|
||||
```bat
|
||||
check-sense.bat
|
||||
start-sense.bat
|
||||
stop-sense.bat
|
||||
```
|
||||
|
||||
检查项至少覆盖配置解析与进程环境优先级、特殊字符不被执行、production/demo 数据库隔离、迁移失败不启动服务、首页 SPA fallback、`/healthz`、MediaMTX Control API、包外工作目录启动与停止、PostgreSQL custom-format 备份及恢复到独立数据库。真实摄像机、目标客户数据库账号、目标浏览器与干净客户机器仍须在授权交付环境验收。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: a169b2323323d9de6304e9b430ddbe9888ea1d25
|
||||
synchronized_at: 2026-08-15T07:16:25Z
|
||||
wiki_revision: 1a452e9aafdfe01580f37f9179584b89516cf992
|
||||
synchronized_at: 2026-08-15T07:59:38Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -134,3 +134,21 @@ synchronized_at: 2026-08-15T07:16:25Z
|
||||
|
||||
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
|
||||
<!-- sense-capabilities-jsonb:end -->
|
||||
|
||||
<!-- sense-media-path-constraint:start -->
|
||||
## Sense 旧媒体路由迁移排错
|
||||
|
||||
旧库迁移出现 `约束 "uni_sense_media_routes_path" 不存在 (SQLSTATE 42704)`,表示旧 `sense_media_routes.path` 由 PostgreSQL 自动命名的唯一约束保护,而新 GORM 模型准备改用唯一索引;GORM 按推导名称删除旧约束时找不到实际名称。修正该名称后若继续出现 `source_ready ... contains null values (SQLSTATE 23502)`,表示非空旧表还缺少当前模型要求的运行态列。
|
||||
|
||||
工单 #95 的兼容迁移只在 PostgreSQL 旧表存在时执行:取得 ACCESS EXCLUSIVE 表锁,确认只有一个单列 `UNIQUE(path)` 约束,将实际约束名规范为 GORM 可识别名称;同时为旧路由初始化保守运行态 `source_ready=false`、`failure_count=0`、`last_error_code=''`,再继续 AutoMigrate。迁移不会把旧路由伪装成已就绪,服务启动后仍由对账恢复真实状态。复合约束、多重 path 约束或其他无法确认的唯一性结构会拒绝迁移并整体回滚。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 停止连接该数据库的全部 Sense 实例,并确认 Sense 与 MediaMTX 相关端口已释放。
|
||||
2. 使用 `backup-sense.bat` 生成 PostgreSQL custom-format 备份;非标准 PostgreSQL 安装目录需通过 `SENSE_POSTGRES_BIN` 指向包含 `pg_dump.exe`、`pg_restore.exe` 的目录。
|
||||
3. 使用 `pg_restore --list <备份文件>` 确认备份可读取,再部署包含 #95 的 Windows 包。
|
||||
4. 先运行 `migrate-sense.bat`;成功后确认旧路由数量不变、运行态列无空值、`path` 仍有唯一索引。
|
||||
5. 再启动 Sense,检查首页、`/healthz`、MediaMTX Control API 和视频服务对账;验证完成后使用 `stop-sense.bat` 停止。
|
||||
|
||||
如果迁移报告不支持的唯一性结构,不要手工删除约束或路由;在备份副本中核对实际约束和业务数据。正式迁移失败时保留错误并从迁移前备份恢复,不通过关闭唯一性绕过迁移。
|
||||
<!-- sense-media-path-constraint:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
|
||||
wiki_revision: f0d16a60406eed6fd6968c6555c599554bbd1fae
|
||||
synchronized_at: 2026-08-11T10:30:56Z
|
||||
wiki_revision: 221fd392c943caab13ee097c19a3d19d4410b65a
|
||||
synchronized_at: 2026-08-15T09:59:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -48,7 +48,7 @@ YoVision 首个可交付目标是在民办寄宿学校以默认 16 路高风险
|
||||
|
||||
### P0
|
||||
|
||||
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。
|
||||
- **SEN-001 独立登录与权限**:Sense 自有用户、角色、菜单、会话和审计;至少覆盖管理员、实施/运维、站点管理员和只读边界。首期采用账号密码直接登录,所有运行模式均不使用验证码;密码保持 6–72 字节且不强制字符复杂度,登录成功与失败必须记录不含秘密的身份审计。
|
||||
- **SEN-002 设备台账**:以 Device 为根实体,通过 `modality` 和 `capabilities` 表达 video/radar/contact/button/wearable/other;首期只完整实现 video,未实现适配器显示 `adapter_not_ready`。
|
||||
- **SEN-003 ONVIF/RTSP 接入**:支持发现或手工添加、Profiles、StreamUri、主/子码流、校时、认证失败、重新探测和凭据更新。
|
||||
- **SEN-004 批量开通**:默认 16 路可导入、预校验、待激活、逐项成功/失败、仅重试失败项;部分成功不做整体回滚。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-95-Sense旧媒体路由唯一约束兼容迁移
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-95-Sense%E6%97%A7%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%94%AF%E4%B8%80%E7%BA%A6%E6%9D%9F%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
|
||||
wiki_revision: 5f3bdf3786f2a72dac5d29236ff466743e2912b5
|
||||
synchronized_at: 2026-08-16T11:31:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 95 Sense旧媒体路由唯一约束兼容迁移
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/95
|
||||
- Wiki 页面:Task-95-Sense旧媒体路由唯一约束兼容迁移
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
#92 修复进入真实旧库后,设备能力字段已成功转换为 JSONB;下一条媒体迁移因 GORM 尝试删除不存在的推导约束名 `uni_sense_media_routes_path` 而报 SQLSTATE 42704。只读核对确认旧 `sense_media_routes.path` 实际由 PostgreSQL 自动命名约束 `sense_media_routes_path_key` 保证唯一,且表中已有 2 条路由。
|
||||
|
||||
隔离回归越过约束错误后进一步确认,旧非空表缺少当前模型要求的运行态列,直接新增 `source_ready NOT NULL` 会报 SQLSTATE 23502。目标是在不删除路由、不削弱 path 唯一性、不伪造媒体已就绪的前提下完成旧表迁移。
|
||||
|
||||
## 最终方案
|
||||
|
||||
在现有 `2026081419000` 媒体迁移事务开头执行 PostgreSQL 专用兼容步骤。仅当旧表存在时取得 ACCESS EXCLUSIVE 锁,从 pg_catalog 读取包含 path 的唯一约束;只接受唯一的单列 `UNIQUE(path)`,复合、多重或冲突结构拒绝迁移并整体回滚。
|
||||
|
||||
确认结构后,把数据库实际约束名规范为 GORM 能识别和移除的名称,让 AutoMigrate 转换为模型的 `idx_sense_media_routes_path` 唯一索引。锁在整个迁移事务提交前持续有效,因此约束切换期间没有并发写入窗口。
|
||||
|
||||
兼容步骤同时为旧路由添加并回填当前模型要求的运行态列:`source_ready=false`、`failure_count=0`、`last_error_code=''`,随后设为 NOT NULL。保守初值表示服务启动后必须重新对账,不把旧路由冒充为已经就绪;`next_retry_at` 保持可空并由 AutoMigrate 建立。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081419000_media.go`:旧约束识别、规范化、锁表和运行态列兼容。
|
||||
- `Sense/server/cmd/migrate/migration/version/2026081419000_media_test.go`:隔离 PostgreSQL 旧表、非空路由、空库、无表、重复执行、唯一性和不安全结构回滚测试。
|
||||
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:错误含义、备份、迁移、验证和回退步骤。
|
||||
- `wiki-docs.json`、`docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md`:任务归档登记和镜像。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 旧约束名不再触发 SQLSTATE 42704 | 通过;隔离和真实 PostgreSQL 均完成媒体迁移 |
|
||||
| 既有媒体路由完整保留 | 通过;真实库迁移前后均为 2 条 |
|
||||
| path 始终具有唯一性保护 | 通过;迁移后 `idx_sense_media_routes_path` 唯一索引有效,重复 path 写入被拒绝 |
|
||||
| 无表、新库、已迁移库和重复兼容 | 通过 |
|
||||
| 不安全结构拒绝并回滚 | 通过;复合 path 约束夹具未发生部分变更 |
|
||||
| Go 全量与隔离 PostgreSQL 回归 | 通过 |
|
||||
| #70 Windows 包真实迁移与启动 smoke | 通过;Web/SPA/health/MediaMTX 200,停止后端口清空 |
|
||||
| Wiki 镜像与任务归档一致 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 隔离 PostgreSQL 17 `TestMediaMigrationOnPostgres`:旧 2 路由、旧约束、运行态回填、空库、无表、重复兼容、唯一性冲突和复合约束回滚全部通过;独立测试数据库每次运行后删除。
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -count=1`:使用隔离 PostgreSQL 通过。
|
||||
- #70 固定 Go 1.26.5、Node 22.22.1、pnpm 9.15.1 production build 与包审计通过。
|
||||
- 迁移前 PostgreSQL custom-format 备份通过 `pg_restore --list`;真实迁移完成,2 条旧路由保留、运行态列无空值、媒体迁移版本登记、唯一索引有效。
|
||||
- Web 首页、SPA、`/healthz` 与 MediaMTX Control API 均返回 200;`stop-sense.bat` 后相关端口无监听。
|
||||
- `git diff --check`:通过。
|
||||
- **未验证部分**:尚未在客户全新 Windows 主机、客户生产 PostgreSQL 账号和真实获准摄像机上验收;当前回归使用本机 PostgreSQL、脱敏业务计数和已配置测试摄像机环境。Harness strict 仍只受既存 #66/#67 归档格式影响。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- PowerShell `Invoke-WebRequest` 在本机受代理环境影响,访问 loopback 时失败;明确绕过代理的本机 HTTP 请求验证服务正常,不属于 Sense 服务端故障。
|
||||
- #95 需先经用户验收并合入 `dev`,随后 #70 才能按依赖顺序完成合并。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `3dd4890` 兼容旧媒体路由约束和运行态列迁移。
|
||||
- `6257859` 记录旧媒体路由迁移排错。
|
||||
- `c088caf` #70 集成 #95 后用于 Windows 包真实回归。
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-16:用户明确验收通过 #95。
|
||||
- 按依赖顺序先将 PR #96 合入 `dev`;`main` 保持不变。
|
||||
@@ -0,0 +1,85 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-97-Sense免验证码登录与管理员密码重置
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-97-Sense%E5%85%8D%E9%AA%8C%E8%AF%81%E7%A0%81%E7%99%BB%E5%BD%95%E4%B8%8E%E7%AE%A1%E7%90%86%E5%91%98%E5%AF%86%E7%A0%81%E9%87%8D%E7%BD%AE.-
|
||||
wiki_revision: 486ebcca10e4cef0bd905df59b928c17006db17e
|
||||
synchronized_at: 2026-08-16T11:04:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 97 Sense免验证码登录与管理员密码重置
|
||||
|
||||
- 类型:安全行为调整 / 缺陷修复
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-15
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/97
|
||||
- Wiki 页面:Task-97-Sense免验证码登录与管理员密码重置
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
GoAdmin 重建后的 Sense 登录页和生产后端重新启用了验证码,与用户确认的账号密码直接登录流程不一致。用户要求所有运行模式恢复免验证码登录,并把当前本地 PostgreSQL 的管理员账号设置为用户指定、满足现行 6–72 字节策略的密码;密码明文不得进入仓库、工单、Wiki 或日志。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 保留冻结 GoAdmin 的 Gin/GORM/JWT/Casbin `Authenticator`、登录路由、Vuex 登录动作、Element Plus 表单和同步身份审计。
|
||||
- 登录 DTO 只保留 `username/password`;production、test、dev 均不再校验验证码。
|
||||
- 登录页移除验证码字段、规则、图标、接口请求、失败刷新和样式;兼容保留 `/api/v1/captcha` 端点及上游存储初始化,便于回退。
|
||||
- Swagger 登录载荷同步为只要求账号和密码。
|
||||
- 修复失败认证分支未把用户名写入审计的问题,使错误密码尝试可按账号追踪。
|
||||
- 目标数据库起初没有任何用户,因此没有执行不安全的直接插入;使用一次性高熵进程令牌走既有 `/api/v1/bootstrap` 安全初始化路径创建 `admin`,密码只在进程内传递。随后验证正确密码成功、错误密码拒绝和成功/失败审计。
|
||||
- 密码最少 6 位、最多 72 字节且不强制字符复杂度的既有策略保持不变;JWT、RBAC、Cookie 和会话有效期未修改。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/common/middleware/handler/auth.go`:移除验证码校验并补齐失败登录用户名审计。
|
||||
- `Sense/server/common/middleware/handler/login.go`:登录载荷只保留账号和密码。
|
||||
- `Sense/server/common/middleware/handler/login_test.go`:覆盖无验证码登录载荷。
|
||||
- `Sense/server/docs/admin/admin_docs.go`、`admin_swagger.json`、`admin_swagger.yaml`:同步登录接口模型。
|
||||
- `Sense/ui/src/views/login/index.vue`:移除验证码 UI、请求和状态。
|
||||
- `Sense/ui/tests/unit/login/loginPage.spec.js`:覆盖登录页只使用账号密码。
|
||||
- Wiki `Product-Requirements`、`Local-Development-and-Verification` 及镜像:记录免验证码登录、安全审计和密码策略边界。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 登录页不显示验证码且不请求验证码接口 | 通过:组件状态、规则与方法均只含账号密码;生产构建通过 |
|
||||
| 所有模式接受仅账号密码的登录载荷 | 通过:后端不再按模式进入 captcha 校验,DTO 定向测试通过 |
|
||||
| 正确密码成功、错误密码拒绝并有脱敏审计 | 通过:本地 production/PostgreSQL smoke 成功;成功与失败审计均可按 admin 查询 |
|
||||
| JWT、RBAC、未登录拒绝不变 | 通过:未认证管理路由返回 401,全量后端测试通过 |
|
||||
| 密码策略仍为 6–72 字节 | 通过:现有密码策略测试通过,相关代码未修改 |
|
||||
| 管理员密码安全设置且仓库无明文 | 通过:空用户库经一次性 bootstrap 创建,跟踪差异秘密扫描无泄漏 |
|
||||
| 前后端测试和 production build | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `go vet ./...`:通过。
|
||||
- `go build ./...`:通过。
|
||||
- `corepack pnpm@9.15.1 exec eslint src/views/login/index.vue tests/unit/login/loginPage.spec.js`:通过。
|
||||
- 前端全量单测:17 个 suite、46 个 test 通过。
|
||||
- `corepack pnpm@9.15.1 run build:prod`:通过;存在既有 Sass、SCSS export、代码生成器和体积 warning,未由本工单引入。
|
||||
- production/PostgreSQL/MediaMTX smoke:安全初始化成功、免验证码登录成功、错误密码拒绝、未认证接口返回 401;身份审计包含首个管理员创建、登录成功和登录失败记录。
|
||||
- `python -m unittest discover -s tests -v`:31 项通过。
|
||||
- `python dev_scripts/check_harness.py --strict`:只因既有 #66/#67 归档缺少当前模板章节失败 4 项,与本工单修改无关。
|
||||
- `git diff --check` 与跟踪差异密码扫描:通过。
|
||||
- 测试结束后 Sense、MediaMTX 均已停止,18080/9997 无监听;`Sense/ui/node_modules` 与 `Sense/ui/dist` 已清理。
|
||||
- **未验证部分**:尚未在 #70 最终 Windows ZIP、客户全新 Windows 主机和客户目标浏览器中重新打包验收;#70 与 #97 合入 `dev` 后需重新生成发布包。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 验证码 API 为上游兼容而保留但登录链不使用;若未来永久删除,需单独清理工单核对依赖和回退。
|
||||
- 取消验证码降低自动化暴力尝试阻力;本工单按用户确认保留失败登录审计,但未引入未确认的限流或锁定体系。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `0bbdb27f6db4f9d08445ca02abae9061c81598b8` 恢复免验证码登录并补充测试。
|
||||
- `bc1a01848d1769250b706f207d542b63fee2afa6` 更新 Sense 登录安全边界镜像。
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 2026-08-16:用户明确验收通过 #97。
|
||||
- 按工作流将 PR #98 合入 `dev`;`main` 保持不变。
|
||||
@@ -131,6 +131,14 @@
|
||||
{
|
||||
"page": "Task-69-Sense多边形区域与方向警戒线配置",
|
||||
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-95-Sense旧媒体路由唯一约束兼容迁移",
|
||||
"path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-97-Sense免验证码登录与管理员密码重置",
|
||||
"path": "docs/task/97-Sense免验证码登录与管理员密码重置.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user