Merge remote-tracking branch 'origin/main' into feat/340-syb-excluded-products
# Conflicts: # server/app/goauto/sybimport/handler.go # server/app/goauto/sybimport/service.go # server/app/goauto/sybimport/service_test.go # web/src/views/goauto/syb-products/index.vue
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const afterSalesInitialPasswordEnv = "GOAUTO_AFTER_SALES_INITIAL_PASSWORD"
|
||||
|
||||
var afterSalesUsers = []string{"zengyt", "huangyj", "zhuyt", "wangxy"}
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesRole)
|
||||
}
|
||||
|
||||
// migrateAfterSalesRole creates the internal 售后 role and accounts. It
|
||||
// copies the current purchaser menu/API grants, so the two roles stay aligned
|
||||
// for the current product surface (including yeeke 退货同步 and 退货商品).
|
||||
// The initial password is deliberately supplied only at migration time via an
|
||||
// environment variable; it is never stored in source, logs, or issue text.
|
||||
func migrateAfterSalesRole(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
role, err := ensureAfterSalesRole(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clonePurchaserPermissions(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureAfterSalesUsers(tx, role.RoleId); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureAfterSalesRole(db *gorm.DB) (migrationmodels.SysRole, error) {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, fmt.Errorf("find purchaser role: %w", err)
|
||||
}
|
||||
|
||||
role := migrationmodels.SysRole{}
|
||||
if err := db.Where("role_key = ?", access.RoleAfterSales).
|
||||
Assign(migrationmodels.SysRole{
|
||||
RoleName: "售后", Status: "2", RoleSort: purchaser.RoleSort + 1,
|
||||
Admin: false, DataScope: purchaser.DataScope,
|
||||
Remark: "GoAuto 售后业务角色(系统维护)",
|
||||
}).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
return migrationmodels.SysRole{}, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func clonePurchaserPermissions(db *gorm.DB, roleID int) error {
|
||||
var purchaser migrationmodels.SysRole
|
||||
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT ?, source.menu_id
|
||||
FROM sys_role_menu AS source
|
||||
WHERE source.role_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_menu AS target
|
||||
WHERE target.role_id = ? AND target.menu_id = source.menu_id
|
||||
)`, roleID, purchaser.RoleId, roleID).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser menus: %w", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||
SELECT source.ptype, ?, source.v1, source.v2, source.v3, source.v4, source.v5
|
||||
FROM casbin_rule AS source
|
||||
WHERE source.ptype = 'p' AND source.v0 = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM casbin_rule AS target
|
||||
WHERE target.ptype = source.ptype AND target.v0 = ?
|
||||
AND target.v1 = source.v1 AND target.v2 = source.v2
|
||||
AND COALESCE(target.v3, '') = COALESCE(source.v3, '')
|
||||
AND COALESCE(target.v4, '') = COALESCE(source.v4, '')
|
||||
AND COALESCE(target.v5, '') = COALESCE(source.v5, '')
|
||||
)`, access.RoleAfterSales, access.RolePurchaser, access.RoleAfterSales).Error; err != nil {
|
||||
return fmt.Errorf("clone purchaser API policies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAfterSalesUsers(db *gorm.DB, roleID int) error {
|
||||
missing := make([]string, 0, len(afterSalesUsers))
|
||||
for _, username := range afterSalesUsers {
|
||||
var existing adminmodels.SysUser
|
||||
err := db.Unscoped().Where("username = ?", username).First(&existing).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
missing = append(missing, username)
|
||||
case err != nil:
|
||||
return fmt.Errorf("find after-sales user %q: %w", username, err)
|
||||
case existing.RoleId != roleID:
|
||||
return fmt.Errorf("after-sales user %q already exists with another role", username)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
password := strings.TrimSpace(os.Getenv(afterSalesInitialPasswordEnv))
|
||||
if password == "" {
|
||||
return fmt.Errorf("%s must be set when creating after-sales users", afterSalesInitialPasswordEnv)
|
||||
}
|
||||
// Validate before creating any account; the value itself is never logged or
|
||||
// written to a repository artifact.
|
||||
if len(password) < 1 {
|
||||
return errors.New("after-sales initial password is empty")
|
||||
}
|
||||
for _, username := range missing {
|
||||
user := adminmodels.SysUser{
|
||||
Username: username, Password: password, NickName: username,
|
||||
RoleId: roleID, Status: "2", Remark: "GoAuto 售后账号",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("create after-sales user %q: %w", username, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureAfterSalesRoleAndUsersIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &adminmodels.SysUser{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS sys_role_menu (role_id integer NOT NULL, menu_id integer NOT NULL, PRIMARY KEY (role_id, menu_id))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`CREATE TABLE IF NOT EXISTS casbin_rule (id integer PRIMARY KEY AUTOINCREMENT, ptype varchar(100), v0 varchar(100), v1 varchar(100), v2 varchar(100), v3 varchar(100), v4 varchar(100), v5 varchar(100))`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
purchaser := migrationmodels.SysRole{RoleName: "采购员", RoleKey: access.RolePurchaser, Status: "2", RoleSort: 20, DataScope: "1"}
|
||||
if err = db.Create(&purchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_menu (menu_name, title, menu_type, parent_id) VALUES ('GoAutoYeekeReturns', 'yeeke 退货包裹', 'C', 0)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO sys_role_menu (role_id, menu_id) SELECT ?, menu_id FROM sys_menu`, purchaser.RoleId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`INSERT INTO casbin_rule (ptype, v0, v1, v2) VALUES ('p', ?, '/api/admin/v1/yeeke-returns', 'GET')`, access.RolePurchaser).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPassword := os.Getenv(afterSalesInitialPasswordEnv)
|
||||
defer os.Setenv(afterSalesInitialPasswordEnv, oldPassword)
|
||||
if err = os.Setenv(afterSalesInitialPasswordEnv, "test-only-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err := ensureAfterSalesRole(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = clonePurchaserPermissions(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ensureAfterSalesUsers(db, role.RoleId); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err = db.Model(&adminmodels.SysUser{}).Where("role_id = ?", role.RoleId).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != int64(len(afterSalesUsers)) {
|
||||
t.Fatalf("got %d after-sales users, want %d", count, len(afterSalesUsers))
|
||||
}
|
||||
var user adminmodels.SysUser
|
||||
if err = db.Where("username = ?", afterSalesUsers[0]).First(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("test-only-password")) != nil {
|
||||
t.Fatal("initial password was not stored as a bcrypt hash")
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("ptype = 'p' AND v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("got %d after-sales policies, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// #341 follow-up: the after-sales role was created before #338 added the
|
||||
// return-match API surface. Add only the reviewed return-match grants so the
|
||||
// existing role can use the new SYB action without recreating or changing its
|
||||
// users.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAfterSalesReturnMatch)
|
||||
}
|
||||
|
||||
type afterSalesReturnMatchAPI struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Title string `gorm:"column:title;size:128"`
|
||||
Path string `gorm:"column:path;size:128"`
|
||||
Type string `gorm:"column:type;size:16"`
|
||||
Action string `gorm:"column:action;size:16"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchAPI) TableName() string { return "sys_api" }
|
||||
|
||||
type afterSalesReturnMatchPolicy struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"column:ptype;size:100"`
|
||||
V0 string `gorm:"column:v0;size:100"`
|
||||
V1 string `gorm:"column:v1;size:100"`
|
||||
V2 string `gorm:"column:v2;size:100"`
|
||||
V3 string `gorm:"column:v3;size:100"`
|
||||
V4 string `gorm:"column:v4;size:100"`
|
||||
V5 string `gorm:"column:v5;size:100"`
|
||||
}
|
||||
|
||||
func (afterSalesReturnMatchPolicy) TableName() string { return "casbin_rule" }
|
||||
|
||||
func migrateAfterSalesReturnMatch(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var role struct {
|
||||
RoleID int `gorm:"column:role_id"`
|
||||
}
|
||||
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", access.RoleAfterSales).First(&role).Error; err != nil {
|
||||
return fmt.Errorf("find after-sales role: %w", err)
|
||||
}
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
api := afterSalesReturnMatchAPI{Path: permission.Path, Action: permission.Method}
|
||||
if err := tx.Where("path = ? AND action = ?", permission.Path, permission.Method).
|
||||
Assign(afterSalesReturnMatchAPI{Title: permission.Title, Path: permission.Path, Action: permission.Method, Type: "BUS"}).
|
||||
FirstOrCreate(&api).Error; err != nil {
|
||||
return fmt.Errorf("ensure return-match API: %w", err)
|
||||
}
|
||||
policy := afterSalesReturnMatchPolicy{Ptype: "p", V0: access.RoleAfterSales, V1: permission.Path, V2: permission.Method}
|
||||
var count int64
|
||||
if err := tx.Model(&afterSalesReturnMatchPolicy{}).Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", policy.Ptype, policy.V0, policy.V1, policy.V2).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := tx.Create(&policy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAfterSalesReturnMatchCatalogAndGrants(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationmodels.SysRole{}, &afterSalesReturnMatchAPI{}, &afterSalesReturnMatchPolicy{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&migrationmodels.SysRole{RoleKey: access.RoleAfterSales}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"test-first", "test-repeat"} {
|
||||
if err = migrateAfterSalesReturnMatch(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var apis []afterSalesReturnMatchAPI
|
||||
if err = db.Find(&apis).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(apis) != 8 {
|
||||
t.Fatalf("API count=%d", len(apis))
|
||||
}
|
||||
for _, api := range apis {
|
||||
if api.Path == "" || api.Action == "" {
|
||||
t.Fatalf("empty API path/action: id=%d", api.ID)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&afterSalesReturnMatchPolicy{}).Where("v0 = ?", access.RoleAfterSales).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 8 {
|
||||
t.Fatalf("policy count=%d", count)
|
||||
}
|
||||
// Simulate the original deployed catalogue bug, then repair and repeat.
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("id > 0").Updates(map[string]any{"path": "", "action": ""}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, version := range []string{"repair-first", "repair-repeat"} {
|
||||
if err = migrateFixReturnMatchAPICatalog(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err = db.Model(&afterSalesReturnMatchAPI{}).Where("path = '' OR action = ''").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unrepaired API rows=%d", count)
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Repair the API catalogue rows created by 1789801200000 before the path and
|
||||
// action fields were populated. This is idempotent and only touches the
|
||||
// return-match endpoints.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateFixReturnMatchAPICatalog)
|
||||
}
|
||||
|
||||
func migrateFixReturnMatchAPICatalog(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, permission := range access.AdminAPIs {
|
||||
if len(permission.Path) < len("/api/admin/v1/return-matches") || permission.Path[:len("/api/admin/v1/return-matches")] != "/api/admin/v1/return-matches" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Table("sys_api").Where("title = ?", permission.Title).Updates(map[string]any{
|
||||
"title": permission.Title, "path": permission.Path, "action": permission.Method, "type": "BUS",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBSessionAuthLease)
|
||||
}
|
||||
|
||||
// The lease is additive and contains no credentials. It serializes automatic
|
||||
// SYB re-authentication across sync and purchase writeback workers.
|
||||
func migrateSYBSessionAuthLease(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user