diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5828ced..a49c512 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -1,3 +1,11 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Architecture-and-Code-Map +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- +wiki_revision: c021b5b8251fecaf1cfa4b96d731964f6b04ca52 +synchronized_at: 2026-09-27T02:47:22Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map @@ -520,3 +528,9 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 - `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。 - `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。 - 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。 + +## GoAuto 售后角色与退货模块权限(#341) + +- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。 +- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。 +- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1341571..d03d4f2 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -1,3 +1,11 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Business-Rules-and-Glossary +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- +wiki_revision: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f +synchronized_at: 2026-09-27T02:47:26Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary @@ -680,3 +688,9 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 ### 采购手机环境前提 执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。 + +## 管理端售后角色(#341) + +- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。 +- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。 +- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index c7f93c2..16457c1 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -1,3 +1,11 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Deployment-and-Operations +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.- +wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b +synchronized_at: 2026-09-27T02:47:34Z + + generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Deployment-and-Operations @@ -153,3 +161,9 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。 任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。 + +## #341 售后角色与账号迁移 + +- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。 +- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。 +- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。 diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 0fdb88d..2d68cfd 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -3,6 +3,9 @@ package access // RolePurchaser is the fixed role key used by the GoAuto purchaser account. const RolePurchaser = "purchaser" +// RoleAfterSales is the fixed role key for the internal after-sales users. +const RoleAfterSales = "after_sales" + // APIPermission describes one admin API known to GoAuto. Purchaser marks the // APIs that the purchaser role may call; every other API remains admin-only. type APIPermission struct { diff --git a/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go new file mode 100644 index 0000000..3d7319f --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go @@ -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 +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go new file mode 100644 index 0000000..4c3c693 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_after_sales_role_test.go @@ -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) + } +}