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:
QiuSW
2026-09-29 10:50:54 +08:00
61 changed files with 2907 additions and 274 deletions
@@ -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
})
}
@@ -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)
}
}
@@ -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
})
}