Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1491a9677a | ||
|
|
d4d32c6e89 | ||
|
|
b7038633f0 | ||
|
|
844f611d77 | ||
|
|
d415971268 | ||
|
|
6fe588d5e3 | ||
|
|
17cbfaec85 |
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: ad4a5c3c7e4c2e3631856b3021e11560be2e702e
|
||||
synchronized_at: 2026-09-27T03:42:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
@@ -520,3 +536,14 @@ 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 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
|
||||
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
|
||||
|
||||
|
||||
## #338 退货匹配(2026-09-27)
|
||||
|
||||
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: 57af37bf6acf16f86785c989e5c37dce85a3c588
|
||||
synchronized_at: 2026-09-27T03:42:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
@@ -680,3 +696,14 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
## 管理端售后角色(#341)
|
||||
|
||||
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
|
||||
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
|
||||
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
|
||||
|
||||
|
||||
## 退货匹配(#338)
|
||||
|
||||
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 9c10d971fe1dde057b13972566444a2234dd7f0e
|
||||
synchronized_at: 2026-09-27T03:42:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
@@ -153,3 +169,14 @@ 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。
|
||||
|
||||
|
||||
## #338 退货匹配发布
|
||||
|
||||
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
|
||||
|
||||
@@ -173,6 +173,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleYeekeSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
|
||||
return ModuleYeekeReturns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
|
||||
return ModuleSYBProducts
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -132,6 +135,15 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
|
||||
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
|
||||
|
||||
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
|
||||
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
|
||||
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
|
||||
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
|
||||
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
|
||||
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
|
||||
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
|
||||
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "POST", true},
|
||||
|
||||
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
|
||||
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
|
||||
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
|
||||
|
||||
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
|
||||
if role == "admin" || role == "purchaser" {
|
||||
if role == "admin" || role == "purchaser" || role == "after_sales" {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := allowedOperator(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,14 @@ func operatorFromContext(c *gin.Context) (string, string) {
|
||||
return role, username
|
||||
}
|
||||
|
||||
// requireCanPurchase mirrors the admin/purchaser write gate this codebase
|
||||
// requireCanPurchase mirrors the admin/purchaser/after-sales write gate this codebase
|
||||
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
|
||||
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
|
||||
// writes and require it; the two list/detail read endpoints do not.
|
||||
func requireCanPurchase(c *gin.Context) bool {
|
||||
role, _ := operatorFromContext(c)
|
||||
if role != "admin" && role != "purchaser" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"})
|
||||
if role != "admin" && role != "purchaser" && role != "after_sales" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作退货匹配"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package returnmatch
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestReturnMatchWriteRoles(t *testing.T) {
|
||||
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
|
||||
want := role == "admin" || role == "purchaser" || role == "after_sales"
|
||||
if got := requireCanPurchase(c); got != want {
|
||||
t.Fatalf("role %q allowed=%v want %v", role, got, want)
|
||||
}
|
||||
if !want && w.Code != 403 {
|
||||
t.Fatalf("denied role status=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// InitRouter mounts the #338 return-matching admin surface. List/detail are
|
||||
// readable by any authenticated admin user; the write actions (batch match,
|
||||
// confirm, cancel, remark) additionally require admin/purchaser via
|
||||
// confirm, cancel, remark) additionally require admin/purchaser/after-sales via
|
||||
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
relatedPurchaseCandidates() { return this.related.selected.filter(row => this.isRelatedPurchaseCandidate(row)) },
|
||||
stockColorOptions() { return this.stockSpecValues('color').filter(item => item.priceCent !== null && item.priceCent !== undefined && item.priceCent >= 0) },
|
||||
stockSizeOptions() { return this.stockSpecValues('size') },
|
||||
|
||||
@@ -368,7 +368,7 @@ export default {
|
||||
computed: {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') || roles.includes('after_sales') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
|
||||
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
@@ -858,4 +858,3 @@ export default {
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canStartSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
|
||||
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser' || role === 'after_sales') },
|
||||
hasRunningSync() { return this.items.some(item => item.status === 'running') }
|
||||
},
|
||||
created() { this.load() },
|
||||
|
||||
Reference in New Issue
Block a user