From 47a1d4d0efa87ceb4dae1b2a24cfc6cc7275ffc6 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Sun, 2 Aug 2026 16:29:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E8=A8=AD=E5=82=99=E8=A8=BB?= =?UTF-8?q?=E5=86=8A=20+=20=E6=A8=A1=E5=9E=8B=E5=85=B1=E4=BA=AB=20backend?= =?UTF-8?q?=EF=BC=88B=20=E8=A8=AD=E5=82=99=E7=AE=A1=E7=90=86=20+=20C=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=85=B1=E4=BA=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/api/device_register.go | 216 +++++ .../internal/api/device_register_test.go | 250 ++++++ visionA-backend/internal/api/devices.go | 5 + visionA-backend/internal/api/errors.go | 8 + visionA-backend/internal/api/models.go | 21 +- .../internal/api/models_download_test.go | 67 +- .../internal/api/models_sharing.go | 791 ++++++++++++++++++ .../internal/api/models_sharing_test.go | 514 ++++++++++++ .../internal/db/migrate_0006_db_test.go | 238 ++++++ visionA-backend/internal/device/device.go | 34 + .../device/inmemory_repository_test.go | 71 ++ .../internal/device/postgres_repository.go | 32 + .../device/postgres_repository_db_test.go | 105 +++ .../internal/model/inmemory_sharing_test.go | 152 ++++ visionA-backend/internal/model/model.go | 401 +++++++++ .../internal/model/postgres_repository.go | 22 +- .../internal/model/postgres_sharing.go | 414 +++++++++ .../model/postgres_sharing_db_test.go | 295 +++++++ visionA-backend/internal/model/presets.go | 3 + .../migrations/0006_model_sharing.down.sql | 17 + .../migrations/0006_model_sharing.up.sql | 49 ++ 21 files changed, 3688 insertions(+), 17 deletions(-) create mode 100644 visionA-backend/internal/api/device_register.go create mode 100644 visionA-backend/internal/api/device_register_test.go create mode 100644 visionA-backend/internal/api/models_sharing.go create mode 100644 visionA-backend/internal/api/models_sharing_test.go create mode 100644 visionA-backend/internal/db/migrate_0006_db_test.go create mode 100644 visionA-backend/internal/model/inmemory_sharing_test.go create mode 100644 visionA-backend/internal/model/postgres_sharing.go create mode 100644 visionA-backend/internal/model/postgres_sharing_db_test.go create mode 100644 visionA-backend/migrations/0006_model_sharing.down.sql create mode 100644 visionA-backend/migrations/0006_model_sharing.up.sql diff --git a/visionA-backend/internal/api/device_register.go b/visionA-backend/internal/api/device_register.go new file mode 100644 index 0000000..e729df6 --- /dev/null +++ b/visionA-backend/internal/api/device_register.go @@ -0,0 +1,216 @@ +// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。 +// +// 「註冊」語意軸(feature-device-mgmt-tdd §3 / §4,api/api-device-mgmt.md): +// - register:把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。 +// - unregister:把 registered_at 清成 NULL(退回未註冊),**保留裝置列**。 +// +// 🔴 與 unpair 完全不同(TDD §1 紅線):unpair 軟刪整台 + cascade 撤 token(device 從清單 +// 消失);unregister 只清單欄 registered_at(device 仍在清單、顯示為未註冊)。兩端點各走各的, +// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token,也**不改** devicesUnpairHandler。 +// +// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent),用 UUID `:id` 識別 +// (對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。 + +package api + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "visiona-backend/internal/device" +) + +// deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5,兩端點一致): +// +// 1. 缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough) +// 2. :id 空 → 400 VALIDATION_FAILED +// 3. Get device:ErrNotFound → 404;其他 DB error → WriteDBError +// 4. owner 檢查(IDOR 主防線):d.OwnerUserID != userID → 403 FORBIDDEN +// 5. representative 檢查:d.IsRepresentative → 409 REPRESENTATIVE_DEVICE +// +// 回傳 (device, userID, ok);ok=false 時已寫好回應,caller 直接 return。 +// +// owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略(TDD §7.2 IDOR)。 +func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) { + id := c.Param("id") + if id == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil) + return nil, "", false + } + + // Phase 0.7 security fix C1:強制要求 UserContext 非空(見既有 devices.go 範式)。 + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return nil, "", false + } + userID := uc.UserID + + d, err := deps.DeviceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return nil, "", false + } + // DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get device", err) + return nil, "", false + } + + // owner 檢查(IDOR 主防線,TDD §7.1/§7.2):沿用既有 handler 慣例(devices.go:197-201)。 + if d.OwnerUserID != userID { + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil) + return nil, "", false + } + + // representative 檢查(TDD §7.3):representative 是 agent 連線佔位、非真 USB, + // 註冊語意不適用。縱深——即使 List 已濾掉 representative(前端拿不到其 UUID), + // handler 仍自己擋;repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。 + if d.IsRepresentative { + WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice, + "representative device cannot be registered", nil) + return nil, "", false + } + + return d, userID, true +} + +// devicesRegisterHandler 實作 POST /api/devices/:id/register。 +// +// 行為順序(api-device-mgmt.md §1):共用前置(1-5)→ 已註冊檢查(6,409 ALREADY_REGISTERED) +// → SetRegistered(now())(7)→ 200 + 更新後 DeviceListItem(registered_at 非 null)。 +func devicesRegisterHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.DeviceRepo == nil { + WriteNotImplemented(c, "device repo not configured") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + d, userID, ok := deviceRegisterCommon(c, deps, ctx) + if !ok { + return + } + + // 已註冊檢查(TDD §3.2):registered_at 非 nil → 409 ALREADY_REGISTERED。 + // 前端據此顯示「此裝置已註冊」並 refetch。 + if d.RegisteredAt != nil { + WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered, + "device already registered", nil) + return + } + + now := time.Now().UTC() + if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil { + if errors.Is(err, device.ErrNotFound) { + // 競態:Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。 + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return + } + WriteDBError(c, deps.Logger, "register device", err) + return + } + + logOrDefault(deps.Logger).Info("devices: registered", + "device_id", d.ID, + "user_id", userID, + "request_id", RequestIDFrom(c)) + + writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID) + } +} + +// devicesUnregisterHandler 實作 POST /api/devices/:id/unregister(退回未註冊)。 +// +// 行為順序(api-device-mgmt.md §2):共用前置(1-5)→ SetRegistered(nil)(冪等,未註冊也回 200) +// → 200 + 更新後 DeviceListItem(registered_at=null)。 +// +// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(TDD §1.2)。與 unpair 各走各的。 +func devicesUnregisterHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.DeviceRepo == nil { + WriteNotImplemented(c, "device repo not configured") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + d, userID, ok := deviceRegisterCommon(c, deps, ctx) + if !ok { + return + } + + // 冪等(TDD §4.1 步驟 2):不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL + // 的列 UPDATE 到相同值、RowsAffected 仍為 1(WHERE 命中),避免使用者連點兩次第二次報錯。 + if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil { + if errors.Is(err, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return + } + WriteDBError(c, deps.Logger, "unregister device", err) + return + } + + logOrDefault(deps.Logger).Info("devices: unregistered", + "device_id", d.ID, + "user_id", userID, + "request_id", RequestIDFrom(c)) + + writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID) + } +} + +// writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。 +// +// 為什麼重新 Get 而非就地拼裝:SetRegistered 只回 error,最新的 registered_at / updated_at +// 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。 +// +// register/unregister 後 device 必然存在(剛剛才 UPDATE 成功),Get 理論上不會 NotFound; +// 若極端競態下被刪,回 404(不 panic)。 +func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) { + d, err := deps.DeviceRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return + } + WriteDBError(c, deps.Logger, "get device after register", err) + return + } + + // tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get,避免被前面 DB 呼叫吃掉 → R-3 誤判)。 + tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer tunnelCancel() + tunnelAlive, lastSeen := resolveTunnelStatus( + tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", RequestIDFrom(c)) + + item := DeviceListItem{ + ID: d.ID, + Name: d.Name, + DeviceType: d.DeviceType, + SerialNumber: d.SerialNumber, + AgentID: d.AgentID, + RegisteredAt: d.RegisteredAt, + RemoteStatus: d.RemoteStatus, + LastSeenAt: d.LastSeenAt, + LastConnectedAt: d.LastConnectedAt, + USBStatus: d.Status, + TunnelOnline: tunnelAlive, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } + if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() { + ls := lastSeen + item.LastSeenAt = &ls + } + + WriteSuccess(c, http.StatusOK, item) +} diff --git a/visionA-backend/internal/api/device_register_test.go b/visionA-backend/internal/api/device_register_test.go new file mode 100644 index 0000000..feea1ec --- /dev/null +++ b/visionA-backend/internal/api/device_register_test.go @@ -0,0 +1,250 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/device" +) + +// newRegisterFixture 建 router(InMemory repo + 無 session),user context = demo-user。 +// 回傳 router + repo 供測試直接塞 device / 驗 registered_at。 +func newRegisterFixture(t *testing.T) (*gin.Engine, *device.InMemoryRepository) { + t.Helper() + repo := device.NewInMemoryRepository() + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext("demo-user", "")) + g := r.Group("/api") + registerDeviceRoutes(g, Deps{ + DeviceRepo: repo, + SessionStore: &fakeSessionStore{}, + }) + return r, repo +} + +func postRegister(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil)) + return w +} + +// errCodeOf 解析錯誤回應的 error.code。 +func errCodeOf(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var eb ErrorBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb), "body=%s", w.Body.String()) + require.NotNil(t, eb.Error) + return eb.Error.Code +} + +// dataItemOf 解析成功回應的 data(DeviceListItem map)。 +func dataItemOf(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var sb SuccessBody + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String()) + item, ok := sb.Data.(map[string]any) + require.True(t, ok, "data should be object, body=%s", w.Body.String()) + return item +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +// TestRegister_Success 未註冊 → register → 200 且 registered_at 非 null。 +func TestRegister_Success(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", DeviceType: "kl520", + SerialNumber: "0xAAAA", // 未註冊:RegisteredAt 留 nil + })) + + w := postRegister(t, r, "/api/devices/d1/register") + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + item := dataItemOf(t, w) + assert.Equal(t, "d1", item["id"]) + assert.NotNil(t, item["registered_at"], "register 後 registered_at 應非 null") + assert.NotEmpty(t, item["registered_at"]) + + // repo 端也確認翻轉。 + got, err := repo.Get(context.Background(), "d1") + require.NoError(t, err) + require.NotNil(t, got.RegisteredAt) +} + +// TestRegister_AlreadyRegistered 已註冊再 register → 409 ALREADY_REGISTERED。 +func TestRegister_AlreadyRegistered(t *testing.T) { + r, repo := newRegisterFixture(t) + past := time.Now().UTC().Add(-time.Hour) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", + RegisteredAt: &past, + })) + + w := postRegister(t, r, "/api/devices/d1/register") + require.Equal(t, http.StatusConflict, w.Code) + assert.Equal(t, ErrCodeAlreadyRegistered, errCodeOf(t, w)) +} + +// TestRegister_NotOwner 非 owner → 403 FORBIDDEN(IDOR 主防線)。 +func TestRegister_NotOwner(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA", + })) + + w := postRegister(t, r, "/api/devices/d1/register") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w)) +} + +// TestRegister_NotFound device 不存在 → 404。 +func TestRegister_NotFound(t *testing.T) { + r, _ := newRegisterFixture(t) + w := postRegister(t, r, "/api/devices/ghost/register") + require.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w)) +} + +// TestRegister_Representative representative device → 409 REPRESENTATIVE_DEVICE。 +func TestRegister_Representative(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true, + })) + + w := postRegister(t, r, "/api/devices/rep/register") + require.Equal(t, http.StatusConflict, w.Code) + assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w), + "representative 用 REPRESENTATIVE_DEVICE 碼區分於 ALREADY_REGISTERED") +} + +// TestRegister_MissingUserContext 缺 UserContext → 500(auth 沒配好不可 fallthrough)。 +func TestRegister_MissingUserContext(t *testing.T) { + repo := device.NewInMemoryRepository() + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", + })) + r := gin.New() + r.Use(RequestIDMiddleware()) + // 刻意不注入 UserContext。 + g := r.Group("/api") + registerDeviceRoutes(g, Deps{DeviceRepo: repo, SessionStore: &fakeSessionStore{}}) + + w := postRegister(t, r, "/api/devices/d1/register") + require.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, ErrCodeInternalError, errCodeOf(t, w)) +} + +// --------------------------------------------------------------------------- +// unregister +// --------------------------------------------------------------------------- + +// TestUnregister_Success 已註冊 → unregister → 200 且 registered_at=null,device 仍在 List。 +func TestUnregister_Success(t *testing.T) { + r, repo := newRegisterFixture(t) + past := time.Now().UTC().Add(-time.Hour) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", + RegisteredAt: &past, + })) + + w := postRegister(t, r, "/api/devices/d1/unregister") + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + item := dataItemOf(t, w) + assert.Nil(t, item["registered_at"], "unregister 後 registered_at 應為 null") + + // device 仍存在(未軟刪、保留列)。 + got, err := repo.Get(context.Background(), "d1") + require.NoError(t, err, "unregister 不軟刪、device 應仍在") + assert.Nil(t, got.RegisteredAt) + + // 仍列在 List。 + list, err := repo.List(context.Background(), "demo-user") + require.NoError(t, err) + require.Len(t, list, 1, "unregister 後 device 仍在清單(與 unpair 不同)") +} + +// TestUnregister_IdempotentWhenUnregistered 未註冊 → unregister → 200 冪等 no-op。 +func TestUnregister_IdempotentWhenUnregistered(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", + // RegisteredAt nil = 未註冊 + })) + + w := postRegister(t, r, "/api/devices/d1/unregister") + require.Equal(t, http.StatusOK, w.Code, "未註冊 unregister 應冪等回 200,body=%s", w.Body.String()) + item := dataItemOf(t, w) + assert.Nil(t, item["registered_at"]) +} + +// TestUnregister_NotOwner 非 owner → 403。 +func TestUnregister_NotOwner(t *testing.T) { + r, repo := newRegisterFixture(t) + past := time.Now().UTC() + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA", + RegisteredAt: &past, + })) + + w := postRegister(t, r, "/api/devices/d1/unregister") + require.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w)) +} + +// TestUnregister_Representative representative → 409 REPRESENTATIVE_DEVICE。 +func TestUnregister_Representative(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true, + })) + + w := postRegister(t, r, "/api/devices/rep/unregister") + require.Equal(t, http.StatusConflict, w.Code) + assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w)) +} + +// TestUnregister_NotFound device 不存在 → 404。 +func TestUnregister_NotFound(t *testing.T) { + r, _ := newRegisterFixture(t) + w := postRegister(t, r, "/api/devices/ghost/unregister") + require.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w)) +} + +// TestRegisterUnregister_RoundTrip register → 綠,unregister → 退回,device 全程保留。 +func TestRegisterUnregister_RoundTrip(t *testing.T) { + r, repo := newRegisterFixture(t) + require.NoError(t, repo.Save(context.Background(), &device.Device{ + ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA", + })) + + // register + w := postRegister(t, r, "/api/devices/d1/register") + require.Equal(t, http.StatusOK, w.Code) + got, _ := repo.Get(context.Background(), "d1") + require.NotNil(t, got.RegisteredAt) + + // unregister + w = postRegister(t, r, "/api/devices/d1/unregister") + require.Equal(t, http.StatusOK, w.Code) + got, _ = repo.Get(context.Background(), "d1") + require.Nil(t, got.RegisteredAt) + + // device 全程未消失。 + list, _ := repo.List(context.Background(), "demo-user") + require.Len(t, list, 1) +} diff --git a/visionA-backend/internal/api/devices.go b/visionA-backend/internal/api/devices.go index 0c1c318..b5abce0 100644 --- a/visionA-backend/internal/api/devices.go +++ b/visionA-backend/internal/api/devices.go @@ -41,6 +41,11 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) { // Unpair(雛形實作:軟刪 DeviceRepo + CloseSession) g.POST("/devices/:id/unpair", devicesUnpairHandler(deps)) + // 註冊軸(feature-device-mgmt P0,純雲端 DB 操作、UUID :id、不 proxy)。 + // register:registered_at NULL→now();unregister:清 registered_at(保留列,與 unpair 分開)。 + g.POST("/devices/:id/register", devicesRegisterHandler(deps)) + g.POST("/devices/:id/unregister", devicesUnregisterHandler(deps)) + // ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打 // local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket, // 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial, diff --git a/visionA-backend/internal/api/errors.go b/visionA-backend/internal/api/errors.go index 514873c..c995b2a 100644 --- a/visionA-backend/internal/api/errors.go +++ b/visionA-backend/internal/api/errors.go @@ -21,6 +21,14 @@ const ( ErrCodeInvalidSignature = "INVALID_SIGNATURE" // ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。 ErrCodeConflict = "CONFLICT" + // ErrCodeAlreadyRegistered 對齊 HTTP 409:對已註冊(registered_at 非 null)的 device + // 再次呼叫 register。前端據此顯示「此裝置已註冊」並 refetch(feature-device-mgmt-tdd §3.2)。 + ErrCodeAlreadyRegistered = "ALREADY_REGISTERED" + // ErrCodeRepresentativeDevice 對齊 HTTP 409:對 representative device(agent 連線佔位、 + // 非真實 USB)呼叫 register/unregister。註冊語意只適用真實 USB device + // (feature-device-mgmt-tdd §7.3)。與 ALREADY_REGISTERED 分開,讓 FE/TEST 能區分 + // 「已註冊」與「不可註冊的裝置類型」兩種 409。 + ErrCodeRepresentativeDevice = "REPRESENTATIVE_DEVICE" // ErrCodeServiceUnavailable 對齊 HTTP 503。 // DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道 // 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。 diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go index cd62dff..208790a 100644 --- a/visionA-backend/internal/api/models.go +++ b/visionA-backend/internal/api/models.go @@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) { // Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。 g.GET("/models/:id/download", modelsDownloadHandler(deps)) + // 模型共享(library / profile / visibility / shares)。 + registerModelSharingRoutes(g, deps) + // load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent) g.POST("/models/:id/load-to-device", func(c *gin.Context) { WriteNotImplemented(c, "models.load-to-device — pending Phase 1") @@ -64,9 +67,12 @@ type ModelResponse struct { // snake_case 對齊本 DTO 既有慣例(target_chip / file_size / created_at); // 前端 model-store normalize 雙吃 snake/camel,input_shape(snake) 在其讀取範圍內。 // omitempty:上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。 - InputShape []int `json:"input_shape,omitempty"` - Classes []string `json:"classes,omitempty"` - Framework string `json:"framework,omitempty"` + InputShape []int `json:"input_shape,omitempty"` + Classes []string `json:"classes,omitempty"` + Framework string `json:"framework,omitempty"` + // Visibility:模型共享功能新增。既有前端未讀此欄不受影響(加欄相容); + // 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。 + Visibility string `json:"visibility,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` UploadedAt *time.Time `json:"uploaded_at,omitempty"` @@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse { InputShape: m.InputShape, Classes: m.Classes, Framework: m.Framework, + Visibility: m.Visibility, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, UploadedAt: m.UploadedAt, @@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc { WriteDBError(c, deps.Logger, "get model", err) return } - // 第一階段 owner-only(B 分享後續階段);非 owner 回 403。 - if m.OwnerUserID != userID { - WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) + // 模型共享放寬:owner-only → 共享權限檢查(TDD §5 download 連帶變更)。 + // 與 profile 可見性共用同一 canAccessModel(single source of truth,杜絕邏輯漂移,SEC-2)。 + // 不命中回 404(不是 403,防 enumeration,與 profile 一致,SEC-1)。 + if canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) == model.AccessNone { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } diff --git a/visionA-backend/internal/api/models_download_test.go b/visionA-backend/internal/api/models_download_test.go index 6faf712..7cc2fb0 100644 --- a/visionA-backend/internal/api/models_download_test.go +++ b/visionA-backend/internal/api/models_download_test.go @@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) { assert.Contains(t, w.Body.String(), ErrCodeNotFound) } -func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) { +// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變(TDD §5 download 放寬): +// 非 owner 且無任何可見性(private model)下載,回 404(不是 403)——防 enumeration(SEC-1), +// 與 profile 的 canAccessModel 判斷一致(single source of truth)。 +func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) { iss := &fakeIssuer{token: "fdt_x"} - // 登入 user = demo-user,但 model owner = other-user + // 登入 user = demo-user,但 model owner = other-user,且 model 為 private(預設)。 r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef") @@ -228,9 +231,63 @@ func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusForbidden, w.Code) - assert.Contains(t, w.Body.String(), ErrCodeForbidden) - assert.Equal(t, 0, iss.calls, "should not issue token for non-owner") + assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404(防 enumeration)") + assert.Contains(t, w.Body.String(), ErrCodeNotFound) + assert.Equal(t, 0, iss.calls, "should not issue token when no access") +} + +// TestModelsDownload_PublicModelNonOwner 驗證 public model 非 owner 也能下載(共享放寬)。 +func TestModelsDownload_PublicModelNonOwner(t *testing.T) { + iss := &fakeIssuer{token: "fdt_pub"} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-pub", "other-user", "models/other-user/pub.nef") + // owner 把 model 設為 public。 + m, err := repo.Get(context.Background(), "m-pub") + require.NoError(t, err) + m.Visibility = model.VisibilityPublic + require.NoError(t, repo.Save(context.Background(), m)) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-pub/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "public model 非 owner 應可下載,body=%s", w.Body.String()) + assert.Equal(t, 1, iss.calls, "public model 應簽 download token") +} + +// TestModelsDownload_SharedModelNonOwner 驗證被 restricted 分享的 grantee 也能下載。 +func TestModelsDownload_SharedModelNonOwner(t *testing.T) { + iss := &fakeIssuer{token: "fdt_share"} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-shared", "other-user", "models/other-user/shared.nef") + // owner 把 model(private)分享給 demo-user(viewer)。 + require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{ + ModelID: "m-shared", + GranteeUserID: "demo-user", + Role: "viewer", + GrantedBy: "other-user", + })) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-shared/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "被分享的 grantee 應可下載,body=%s", w.Body.String()) + assert.Equal(t, 1, iss.calls, "shared model 應簽 download token") +} + +// TestModelsDownload_OwnerStillWorks 回歸:既有 owner 下載仍正常(不因放寬而退化)。 +func TestModelsDownload_OwnerStillWorks(t *testing.T) { + iss := &fakeIssuer{token: "fdt_owner"} + r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user") + seedConvertedModel(t, repo, "m-mine", "demo-user", "models/demo-user/mine.nef") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/m-mine/download", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "owner 下載應仍正常,body=%s", w.Body.String()) + assert.Equal(t, 1, iss.calls) } // ========================================================================== diff --git a/visionA-backend/internal/api/models_sharing.go b/visionA-backend/internal/api/models_sharing.go new file mode 100644 index 0000000..3b7c0ba --- /dev/null +++ b/visionA-backend/internal/api/models_sharing.go @@ -0,0 +1,791 @@ +// models_sharing.go — 模型共享(Model Sharing)的 handler。 +// +// 端點(對齊 api/api-model-sharing.md): +// - GET /api/models/library 共享庫列表(cursor 分頁 + sort/order/q/filter) +// - GET /api/models/:id/profile 模型 profile(權限裁剪;不命中回 404) +// - PATCH /api/models/:id/visibility 設公開對象(owner-only) +// - GET /api/models/:id/shares 列授權清單(owner-only) +// - PUT /api/models/:id/shares 加/更新 grantee 授權(owner-only) +// - DELETE /api/models/:id/shares/:userId 移除 grantee 授權(owner-only) +// +// 核心安全設計:所有可見性判斷走唯一的 canAccessModel(single source of truth,避免 +// profile / download 兩處邏輯漂移,TDD §6 SEC-2);enumeration 防護一律回 404(SEC-1)。 +// +// 對齊:feature-model-sharing-tdd.md §4/§5/§6、api/api-model-sharing.md。 + +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "visiona-backend/internal/auth" + "visiona-backend/internal/model" +) + +// registerModelSharingRoutes 註冊模型共享相關 routes(掛在既有 /api group,走 AuthMiddleware)。 +func registerModelSharingRoutes(g *gin.RouterGroup, deps Deps) { + g.GET("/models/library", modelsLibraryHandler(deps)) + g.GET("/models/:id/profile", modelsProfileHandler(deps)) + g.PATCH("/models/:id/visibility", modelsSetVisibilityHandler(deps)) + g.GET("/models/:id/shares", modelsListSharesHandler(deps)) + g.PUT("/models/:id/shares", modelsPutShareHandler(deps)) + g.DELETE("/models/:id/shares/:userId", modelsDeleteShareHandler(deps)) +} + +// ========================================================================== +// canAccessModel — single source of truth(可見性判斷) +// ========================================================================== + +// canAccessModel 計算 uc 對 m 的有效 AccessLevel。這是 profile / download / (未來) load 的 +// 唯一權限判斷入口——絕不在別處複製一份可見性邏輯(TDD §6 SEC-2)。 +// +// 判斷順序(取最高權限): +// 1. owner(m.OwnerUserID == uc.UserID)→ AccessOwner +// 2. share 命中 → editor / viewer(依 share.role) +// 3. visibility=public → viewer +// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewer(SEC-4 tenant 邊界) +// 5. 皆不命中 → AccessNone +// +// preset 由呼叫端(handler)在進 canAccessModel 前處理(preset 無 owner、公用),不走此函式。 +// +// shareLookup 為查 (modelID, granteeUserID) 分享的函式(注入以利測試 / 共用 repo); +// 傳 nil 時視為「無任何分享」(僅 visibility 判斷)。 +func canAccessModel(ctx context.Context, uc *auth.UserContext, m *model.Model, + shareLookup func(ctx context.Context, modelID, granteeUserID string) (*model.ModelShare, error), +) model.AccessLevel { + if uc == nil || uc.UserID == "" || m == nil { + return model.AccessNone + } + // 1. owner + if m.OwnerUserID == uc.UserID { + return model.AccessOwner + } + // 2. share 命中 + if shareLookup != nil { + if s, err := shareLookup(ctx, m.ID, uc.UserID); err == nil && s != nil { + if s.Role == "editor" { + return model.AccessEditor + } + return model.AccessViewer + } + } + // 3. public + if m.Visibility == model.VisibilityPublic { + return model.AccessViewer + } + // 4. tenant(兩者皆非空才可能命中;空 org 一律不落 tenant 可見) + if m.Visibility == model.VisibilityTenant && uc.OrgID != "" && m.OwnerUserID != "" { + if ownerOrg := ownerOrgOf(ctx, m); ownerOrg != "" && ownerOrg == uc.OrgID { + return model.AccessViewer + } + } + return model.AccessNone +} + +// ownerOrgOf 是 tenant 判斷取 owner.org_id 的鉤子。 +// +// 目前 OIDC 不帶 org claim(middleware 未填 UserContext.OrgID,恆空),故 canAccessModel +// 第 4 步的前置 `uc.OrgID != ""` 一定為 false、永遠短路——本函式實務上不會被呼叫到。 +// 保留為明確的擴充點:待 OIDC 補 org claim + repository 提供 owner.org_id 後在此接線。 +// 現階段回空字串(= tenant 不命中,安全預設)。 +func ownerOrgOf(_ context.Context, _ *model.Model) string { + return "" +} + +// ========================================================================== +// GET /api/models/library +// ========================================================================== + +// LibraryItemResponse 是共享庫列表的一列 DTO(api §1)。 +// +// owner 只揭露 id/name/is_me(不揭露 owner email);不含 storage_key / faa_object_key(SEC-3)。 +type LibraryItemResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetChip string `json:"target_chip,omitempty"` + FileSize int64 `json:"file_size"` + Source string `json:"source"` + Status string `json:"status"` + Visibility string `json:"visibility"` + Owner OwnerResponse `json:"owner"` + SharedWithMe bool `json:"shared_with_me"` + MyAccess string `json:"my_access"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OwnerResponse 是裁剪後的 owner 資訊(絕不含 email)。 +type OwnerResponse struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + IsMe bool `json:"is_me"` +} + +// LibraryResponse 是 GET /api/models/library 的 data payload。 +type LibraryResponse struct { + Items []LibraryItemResponse `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` +} + +const ( + libraryDefaultLimit = 20 + libraryMaxLimit = 100 +) + +// modelsLibraryHandler 實作 GET /api/models/library。 +func modelsLibraryHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.ModelRepo == nil { + // 無 repo(最小骨架):至少回 preset(公用、所有人可見)。 + WriteSuccess(c, http.StatusOK, LibraryResponse{Items: presetLibraryItems(c), HasMore: false}) + return + } + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return + } + + q, verr := parseLibraryQuery(c, uc) + if verr != "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, verr, nil) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + + items, hasMore, err := deps.ModelRepo.Library(ctx, q) + if err != nil { + WriteDBError(c, deps.Logger, "list model library", err) + return + } + + resp := LibraryResponse{ + Items: make([]LibraryItemResponse, 0, len(items)), + HasMore: hasMore, + } + for _, it := range items { + resp.Items = append(resp.Items, toLibraryItemResponse(it, uc.UserID)) + } + if hasMore && len(items) > 0 { + last := items[len(items)-1].Model + resp.NextCursor = encodeCursor(q.Sort, last) + } + WriteSuccess(c, http.StatusOK, resp) + } +} + +// parseLibraryQuery 解析 + 驗證 query 參數,回傳 model.LibraryQuery;驗證失敗回錯誤訊息。 +func parseLibraryQuery(c *gin.Context, uc *auth.UserContext) (model.LibraryQuery, string) { + q := model.LibraryQuery{ + UserID: uc.UserID, + UserOrgID: uc.OrgID, // OIDC 現況恆空 → tenant 不命中 + TargetChip: c.Query("target_chip"), + Source: c.Query("source"), + Q: strings.TrimSpace(c.Query("q")), + Limit: libraryDefaultLimit, + } + + // limit:clamp 到 1–100。 + if raw := c.Query("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return q, "limit must be an integer" + } + if n < 1 { + n = 1 + } + if n > libraryMaxLimit { + n = libraryMaxLimit + } + q.Limit = n + } + + // sort 白名單。 + switch c.Query("sort") { + case "", "created_at": + q.Sort = "created_at" + case "name": + q.Sort = "name" + case "file_size": + q.Sort = "file_size" + default: + return q, "sort must be one of: created_at, name, file_size" + } + // order 白名單。 + switch c.Query("order") { + case "", "desc": + q.Order = "desc" + case "asc": + q.Order = "asc" + default: + return q, "order must be asc or desc" + } + + // visibility filter(僅 public / tenant 有意義;其他忽略)。 + switch c.Query("visibility") { + case model.VisibilityPublic, model.VisibilityTenant: + q.Visibility = c.Query("visibility") + } + + // owned filter(true/false)。 + if raw := c.Query("owned"); raw != "" { + b, err := strconv.ParseBool(raw) + if err != nil { + return q, "owned must be a boolean" + } + q.Owned = &b + } + + // cursor(不透明 base64)。 + if raw := c.Query("cursor"); raw != "" { + cur, err := decodeCursor(raw) + if err != nil { + return q, "invalid cursor" + } + q.Cursor = cur + } + + return q, "" +} + +// toLibraryItemResponse 把 LibraryItem 轉 DTO。my_access:owner 由 is_me 覆寫為 owner。 +func toLibraryItemResponse(it *model.LibraryItem, userID string) LibraryItemResponse { + m := it.Model + status := "pending" + if m.UploadedAt != nil { + status = "ready" + } + isMe := m.OwnerUserID == userID + access := it.MyAccess + if isMe { + access = model.AccessOwner + } + return LibraryItemResponse{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + TargetChip: m.TargetChip, + FileSize: m.FileSize, + Source: m.Source, + Status: status, + Visibility: m.Visibility, + Owner: OwnerResponse{ + ID: m.OwnerUserID, + Name: it.OwnerName, + IsMe: isMe, + }, + SharedWithMe: it.SharedWithMe, + MyAccess: access, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +// presetLibraryItems 把 preset 轉成 library DTO(公用、is_me=false、my_access=viewer)。 +func presetLibraryItems(c *gin.Context) []LibraryItemResponse { + presets := model.PresetModels() + out := make([]LibraryItemResponse, 0, len(presets)) + for _, m := range presets { + status := "ready" + out = append(out, LibraryItemResponse{ + ID: m.ID, + Name: m.Name, + TargetChip: m.TargetChip, + FileSize: m.FileSize, + Source: m.Source, + Status: status, + Visibility: m.Visibility, + Owner: OwnerResponse{ID: "", Name: "system", IsMe: false}, + MyAccess: model.AccessViewer, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }) + } + return out +} + +// ========================================================================== +// cursor 編/解碼(不透明 base64) +// ========================================================================== + +// cursorPayload 是 cursor 的 JSON 內容(前端當黑箱)。 +type cursorPayload struct { + V string `json:"v"` // 排序值 + ID string `json:"id"` // tie-breaker +} + +// encodeCursor 依 sort 欄位取 last item 的排序值,組不透明 base64 游標。 +func encodeCursor(sortField string, last *model.Model) string { + var v string + switch sortField { + case "name": + v = last.Name + case "file_size": + v = strconv.FormatInt(last.FileSize, 10) + default: // created_at + v = last.CreatedAt.UTC().Format(time.RFC3339Nano) + } + raw, _ := json.Marshal(cursorPayload{V: v, ID: last.ID}) + return base64.RawURLEncoding.EncodeToString(raw) +} + +// decodeCursor 解 base64 游標;格式錯誤回 error(handler 轉 400)。 +func decodeCursor(s string) (*model.Cursor, error) { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return nil, err + } + var p cursorPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + if p.ID == "" { + return nil, errors.New("cursor missing id") + } + return &model.Cursor{SortValue: p.V, ID: p.ID}, nil +} + +// ========================================================================== +// GET /api/models/:id/profile +// ========================================================================== + +// ProfileResponse 是 GET /api/models/:id/profile 的 data payload(api §2)。 +// +// 絕不含 storage_key / faa_object_key / owner email / file_checksum(SEC-3)。 +type ProfileResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TargetChip string `json:"target_chip,omitempty"` + FileSize int64 `json:"file_size"` + Source string `json:"source"` + Status string `json:"status"` + Visibility string `json:"visibility"` + InputShape []int `json:"input_shape,omitempty"` + Classes []string `json:"classes,omitempty"` + Framework string `json:"framework,omitempty"` + Owner OwnerResponse `json:"owner"` + MyAccess string `json:"my_access"` + CanDownload bool `json:"can_download"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UploadedAt *time.Time `json:"uploaded_at,omitempty"` +} + +// modelsProfileHandler 實作 GET /api/models/:id/profile。 +// +// 可見性檢查為第一步;不命中回 404(不是 403,防 enumeration,SEC-1)。 +func modelsProfileHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + if id == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) + return + } + // preset 公用、任何登入 user 可見。 + if pm, ok := model.PresetByID(id); ok { + WriteSuccess(c, http.StatusOK, presetProfileResponse(pm)) + return + } + if deps.ModelRepo == nil { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + m, ownerName, err := deps.ModelRepo.GetWithOwner(ctx, id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + WriteDBError(c, deps.Logger, "get model profile", err) + return + } + + access := canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) + if access == model.AccessNone { + // enumeration 防護:不揭露「id 存在但你沒權限」,回 404 與「不存在」無法區分。 + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + + WriteSuccess(c, http.StatusOK, toProfileResponse(m, ownerName, uc.UserID, access)) + } +} + +// toProfileResponse 組 profile DTO(依 access 裁剪;不揭露內部 key)。 +// ownerName 由 GetWithOwner join users 帶出(api §2 owner.name);owner 未設 name 時為空。 +func toProfileResponse(m *model.Model, ownerName, userID string, access model.AccessLevel) ProfileResponse { + status := "pending" + if m.UploadedAt != nil { + status = "ready" + } + return ProfileResponse{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + TargetChip: m.TargetChip, + FileSize: m.FileSize, + Source: m.Source, + Status: status, + Visibility: m.Visibility, + InputShape: m.InputShape, + Classes: m.Classes, + Framework: m.Framework, + Owner: OwnerResponse{ + ID: m.OwnerUserID, + Name: ownerName, // join users.name 帶出(SEC-3 白名單:只揭露 id/name/is_me,不含 email) + IsMe: m.OwnerUserID == userID, + }, + MyAccess: access, + CanDownload: access != model.AccessNone, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + UploadedAt: m.UploadedAt, + } +} + +// presetProfileResponse 組 preset 的 profile(公用、viewer、可下載)。 +func presetProfileResponse(m *model.Model) ProfileResponse { + return ProfileResponse{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + TargetChip: m.TargetChip, + FileSize: m.FileSize, + Source: m.Source, + Status: "ready", + Visibility: model.VisibilityPublic, + InputShape: m.InputShape, + Classes: m.Classes, + Framework: m.Framework, + Owner: OwnerResponse{ID: "", Name: "system", IsMe: false}, + MyAccess: model.AccessViewer, + CanDownload: true, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + UploadedAt: m.UploadedAt, + } +} + +// ========================================================================== +// PATCH /api/models/:id/visibility +// ========================================================================== + +// SetVisibilityRequest 是 PATCH visibility 的 body。 +type SetVisibilityRequest struct { + Visibility string `json:"visibility"` +} + +// SetVisibilityResponse 是 PATCH visibility 的 data payload。 +type SetVisibilityResponse struct { + ID string `json:"id"` + Visibility string `json:"visibility"` + UpdatedAt time.Time `json:"updated_at"` +} + +// modelsSetVisibilityHandler 實作 PATCH /api/models/:id/visibility(owner-only)。 +func modelsSetVisibilityHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.ModelRepo == nil { + WriteNotImplemented(c, "model repo not configured") + return + } + id := c.Param("id") + if id == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) + return + } + // preset 不可改 visibility(公用、無 owner)。 + if model.IsPresetID(id) { + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset visibility is fixed", nil) + return + } + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return + } + + var req SetVisibilityRequest + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil) + return + } + if !model.IsValidVisibility(req.Visibility) { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, + "visibility must be one of: private, tenant, public", + []FieldError{{Field: "visibility", Message: "invalid value"}}) + return + } + // tenant 但 user 無 org → 400(無租戶歸屬不能設 tenant 可見,api §3)。 + if req.Visibility == model.VisibilityTenant && uc.OrgID == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, + "cannot set tenant visibility without an organization", + []FieldError{{Field: "visibility", Message: "no org membership"}}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + m, err := deps.ModelRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + WriteDBError(c, deps.Logger, "get model", err) + return + } + // owner-only(SEC-5)。非 owner 回 403(此為「改權限」動作,回 403 合理—— + // 與 profile/download 的 enumeration 情境不同:能走到這代表 model 存在且是寫入意圖)。 + if m.OwnerUserID != uc.UserID { + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) + return + } + // 未 ready(未 finalize)不允許公開(api §3 409)。 + if req.Visibility != model.VisibilityPrivate && m.UploadedAt == nil { + WriteError(c, http.StatusConflict, ErrCodeConflict, + "model must be ready (finalized) before it can be shared", nil) + return + } + + m.Visibility = req.Visibility + now := time.Now().UTC() + m.UpdatedAt = now + if err := deps.ModelRepo.Save(ctx, m); err != nil { + WriteDBError(c, deps.Logger, "save model visibility", err) + return + } + + logOrDefault(deps.Logger).Info("models: visibility updated", + "model_id", m.ID, + "user_id", uc.UserID, + "visibility", req.Visibility, + "request_id", RequestIDFrom(c)) + + WriteSuccess(c, http.StatusOK, SetVisibilityResponse{ + ID: m.ID, + Visibility: m.Visibility, + UpdatedAt: m.UpdatedAt, + }) + } +} + +// ========================================================================== +// GET/PUT/DELETE /api/models/:id/shares — restricted 分享授權管理(owner-only) +// ========================================================================== + +// ShareResponse 是一筆分享授權 DTO(owner 檢視清單用)。 +// +// 只揭露 grantee id + role + 授權時間;不揭露 grantee email(同 owner email 不揭露原則)。 +type ShareResponse struct { + GranteeUserID string `json:"grantee_user_id"` + Role string `json:"role"` + GrantedBy string `json:"granted_by"` + CreatedAt time.Time `json:"created_at"` +} + +// modelsListSharesHandler 實作 GET /api/models/:id/shares(owner-only)。 +func modelsListSharesHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + m, uc, ok := requireOwnedModel(c, deps) + if !ok { + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + shares, err := deps.ModelRepo.ListShares(ctx, m.ID) + if err != nil { + WriteDBError(c, deps.Logger, "list model shares", err) + return + } + out := make([]ShareResponse, 0, len(shares)) + for _, s := range shares { + out = append(out, ShareResponse{ + GranteeUserID: s.GranteeUserID, + Role: s.Role, + GrantedBy: s.GrantedBy, + CreatedAt: s.CreatedAt, + }) + } + _ = uc + WriteSuccess(c, http.StatusOK, gin.H{"shares": out}) + } +} + +// PutShareRequest 是 PUT shares 的 body(加/更新一個 grantee 授權)。 +type PutShareRequest struct { + GranteeUserID string `json:"grantee_user_id"` + Role string `json:"role,omitempty"` // 'viewer'(預設)| 'editor' +} + +// modelsPutShareHandler 實作 PUT /api/models/:id/shares(owner-only;加/更新授權)。 +func modelsPutShareHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + m, uc, ok := requireOwnedModel(c, deps) + if !ok { + return + } + var req PutShareRequest + if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil) + return + } + req.GranteeUserID = strings.TrimSpace(req.GranteeUserID) + if req.GranteeUserID == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, + "grantee_user_id is required", + []FieldError{{Field: "grantee_user_id", Message: "cannot be empty"}}) + return + } + // 不能分享給自己(owner 已有完整權限)。 + if req.GranteeUserID == uc.UserID { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, + "cannot share a model with its owner", nil) + return + } + role := req.Role + if role == "" { + role = "viewer" + } + if role != "viewer" && role != "editor" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, + "role must be viewer or editor", + []FieldError{{Field: "role", Message: "invalid value"}}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + if err := deps.ModelRepo.UpsertShare(ctx, &model.ModelShare{ + ModelID: m.ID, + GranteeUserID: req.GranteeUserID, + Role: role, + GrantedBy: uc.UserID, + }); err != nil { + WriteDBError(c, deps.Logger, "upsert model share", err) + return + } + + logOrDefault(deps.Logger).Info("models: share granted", + "model_id", m.ID, + "user_id", uc.UserID, + "grantee", req.GranteeUserID, + "role", role, + "request_id", RequestIDFrom(c)) + + WriteSuccess(c, http.StatusOK, ShareResponse{ + GranteeUserID: req.GranteeUserID, + Role: role, + GrantedBy: uc.UserID, + CreatedAt: time.Now().UTC(), + }) + } +} + +// modelsDeleteShareHandler 實作 DELETE /api/models/:id/shares/:userId(owner-only;撤銷授權)。 +func modelsDeleteShareHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + m, uc, ok := requireOwnedModel(c, deps) + if !ok { + return + } + grantee := c.Param("userId") + if grantee == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "user id required", nil) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + if err := deps.ModelRepo.DeleteShare(ctx, m.ID, grantee); err != nil { + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "share not found", nil) + return + } + WriteDBError(c, deps.Logger, "delete model share", err) + return + } + + logOrDefault(deps.Logger).Info("models: share revoked", + "model_id", m.ID, + "user_id", uc.UserID, + "grantee", grantee, + "request_id", RequestIDFrom(c)) + + c.Status(http.StatusNoContent) + } +} + +// requireOwnedModel 是分享授權管理 API 的共用前置:取 model + 驗 owner-only。 +// +// 回傳 (model, userContext, ok);ok=false 時已寫好 error response,呼叫端直接 return。 +// preset 不可管理分享(無 owner)→ 403。 +func requireOwnedModel(c *gin.Context, deps Deps) (*model.Model, *auth.UserContext, bool) { + if deps.ModelRepo == nil { + WriteNotImplemented(c, "model repo not configured") + return nil, nil, false + } + id := c.Param("id") + if id == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil) + return nil, nil, false + } + if model.IsPresetID(id) { + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset models cannot be shared", nil) + return nil, nil, false + } + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return nil, nil, false + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + m, err := deps.ModelRepo.Get(ctx, id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return nil, nil, false + } + WriteDBError(c, deps.Logger, "get model", err) + return nil, nil, false + } + if m.OwnerUserID != uc.UserID { + // 分享授權管理是 owner-only 寫入意圖:非 owner 回 403。 + WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) + return nil, nil, false + } + return m, uc, true +} diff --git a/visionA-backend/internal/api/models_sharing_test.go b/visionA-backend/internal/api/models_sharing_test.go new file mode 100644 index 0000000..4aa4575 --- /dev/null +++ b/visionA-backend/internal/api/models_sharing_test.go @@ -0,0 +1,514 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/model" +) + +// canAccessModelForTest 以 userID(無 org)包一層 canAccessModel,方便單元測試。 +func canAccessModelForTest(ctx context.Context, userID string, m *model.Model, + shareLookup func(context.Context, string, string) (*model.ModelShare, error), +) model.AccessLevel { + return canAccessModel(ctx, &auth.UserContext{UserID: userID}, m, shareLookup) +} + +// ========================================================================== +// fixture +// ========================================================================== + +// newSharingFixture 建一個「以 userID 身份登入」的模型共享 route fixture。 +func newSharingFixture(t *testing.T, userID string) (*gin.Engine, *model.InMemoryRepository) { + t.Helper() + repo := model.NewInMemoryRepository() + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext(userID, "")) + g := r.Group("/api") + registerModelRoutes(g, Deps{ + ModelRepo: repo, + MaxUploadSizeMB: 10, + }) + return r, repo +} + +// seedReadyModel 塞一個 ready(已 finalize)的 model,指定 owner + visibility。 +func seedReadyModel(t *testing.T, repo *model.InMemoryRepository, id, owner, visibility string) *model.Model { + t.Helper() + now := time.Now().UTC() + m := &model.Model{ + ID: id, + OwnerUserID: owner, + Name: "model-" + id, + StorageKey: "models/" + owner + "/" + id + ".nef", + FileSize: 1024, + Source: model.SourceUploaded, + Visibility: visibility, + UploadedAt: &now, // ready + } + require.NoError(t, repo.Save(context.Background(), m)) + return m +} + +// decodeData 解 envelope 的 data 到 target。 +func decodeData(t *testing.T, body []byte, target any) { + t.Helper() + var sb SuccessBody + require.NoError(t, json.Unmarshal(body, &sb)) + raw, err := json.Marshal(sb.Data) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, target)) +} + +// ========================================================================== +// GET /api/models/library — 可見性 matrix +// ========================================================================== + +// TestLibrary_VisibilityMatrix 驗證共享庫只列可見 model:我的 + public + shared, +// 不含別人的 private(TDD §4.1 predicate)。 +func TestLibrary_VisibilityMatrix(t *testing.T) { + r, repo := newSharingFixture(t, "me") + + seedReadyModel(t, repo, "mine-priv", "me", model.VisibilityPrivate) // 我的 private → 可見 + seedReadyModel(t, repo, "other-priv", "other", model.VisibilityPrivate) // 別人 private → 不可見 + seedReadyModel(t, repo, "other-pub", "other", model.VisibilityPublic) // 別人 public → 可見 + shared := seedReadyModel(t, repo, "other-shared", "other", model.VisibilityPrivate) + require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{ + ModelID: shared.ID, GranteeUserID: "me", Role: "viewer", GrantedBy: "other", + })) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/library?limit=100", nil) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var resp LibraryResponse + decodeData(t, w.Body.Bytes(), &resp) + + got := map[string]LibraryItemResponse{} + for _, it := range resp.Items { + got[it.ID] = it + } + assert.Contains(t, got, "mine-priv", "我的 private 應可見") + assert.Contains(t, got, "other-pub", "別人 public 應可見") + assert.Contains(t, got, "other-shared", "分享給我的應可見") + assert.NotContains(t, got, "other-priv", "別人 private 不應可見") + + // my_access / is_me / shared_with_me 正確。 + assert.Equal(t, model.AccessOwner, got["mine-priv"].MyAccess) + assert.True(t, got["mine-priv"].Owner.IsMe) + assert.Equal(t, model.AccessViewer, got["other-pub"].MyAccess) + assert.False(t, got["other-pub"].Owner.IsMe) + assert.True(t, got["other-shared"].SharedWithMe, "分享給我的應標 shared_with_me") +} + +// TestLibrary_ExcludesNotReady 驗證未 finalize(pending)的 model 不進共享庫。 +func TestLibrary_ExcludesNotReady(t *testing.T) { + r, repo := newSharingFixture(t, "me") + // pending model(UploadedAt=nil)。 + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", + FileSize: 1, Source: model.SourceUploaded, Visibility: model.VisibilityPublic, + })) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models/library", nil) + r.ServeHTTP(w, req) + var resp LibraryResponse + decodeData(t, w.Body.Bytes(), &resp) + assert.Empty(t, resp.Items, "pending model 不應進共享庫") +} + +// TestLibrary_OwnedFilter 驗證 owned=true 只回我的、owned=false 只回別人分享/公開的。 +func TestLibrary_OwnedFilter(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "mine", "me", model.VisibilityPrivate) + seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) + + // owned=true + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=true", nil)) + var mineOnly LibraryResponse + decodeData(t, w.Body.Bytes(), &mineOnly) + require.Len(t, mineOnly.Items, 1) + assert.Equal(t, "mine", mineOnly.Items[0].ID) + + // owned=false + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=false", nil)) + var othersOnly LibraryResponse + decodeData(t, w.Body.Bytes(), &othersOnly) + require.Len(t, othersOnly.Items, 1) + assert.Equal(t, "pub", othersOnly.Items[0].ID) +} + +// TestLibrary_SearchQ 驗證 q 搜尋 name。 +func TestLibrary_SearchQ(t *testing.T) { + r, repo := newSharingFixture(t, "me") + m1 := seedReadyModel(t, repo, "a", "me", model.VisibilityPrivate) + m1.Name = "yolov5-detect" + require.NoError(t, repo.Save(context.Background(), m1)) + m2 := seedReadyModel(t, repo, "b", "me", model.VisibilityPrivate) + m2.Name = "resnet-classify" + require.NoError(t, repo.Save(context.Background(), m2)) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?q=yolo", nil)) + var resp LibraryResponse + decodeData(t, w.Body.Bytes(), &resp) + require.Len(t, resp.Items, 1) + assert.Equal(t, "a", resp.Items[0].ID) +} + +// TestLibrary_CursorPagination 驗證 cursor 分頁不重複、不遺漏。 +func TestLibrary_CursorPagination(t *testing.T) { + r, repo := newSharingFixture(t, "me") + for i := 0; i < 5; i++ { + m := seedReadyModel(t, repo, string(rune('a'+i)), "me", model.VisibilityPrivate) + // 讓 created_at 有序(sort=name 更穩定,用 name 分頁)。 + _ = m + } + + seen := map[string]bool{} + cursor := "" + pages := 0 + for { + url := "/api/models/library?limit=2&sort=name&order=asc" + if cursor != "" { + url += "&cursor=" + cursor + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) + require.Equal(t, http.StatusOK, w.Code) + var resp LibraryResponse + decodeData(t, w.Body.Bytes(), &resp) + for _, it := range resp.Items { + assert.False(t, seen[it.ID], "id %s 重複出現於分頁", it.ID) + seen[it.ID] = true + } + pages++ + require.Less(t, pages, 10, "分頁不應無限迴圈") + if !resp.HasMore { + break + } + cursor = resp.NextCursor + require.NotEmpty(t, cursor, "has_more=true 時應有 next_cursor") + } + assert.Len(t, seen, 5, "所有 model 應被分頁完整走過一次") +} + +// TestLibrary_InvalidParams 驗證非法 sort / limit / cursor 回 400。 +func TestLibrary_InvalidParams(t *testing.T) { + r, _ := newSharingFixture(t, "me") + for _, url := range []string{ + "/api/models/library?sort=bogus", + "/api/models/library?limit=abc", + "/api/models/library?order=sideways", + "/api/models/library?cursor=!!!notbase64!!!", + "/api/models/library?owned=maybe", + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil)) + assert.Equal(t, http.StatusBadRequest, w.Code, "url=%s should be 400, body=%s", url, w.Body.String()) + assert.Contains(t, w.Body.String(), ErrCodeValidationFailed) + } +} + +// ========================================================================== +// GET /api/models/:id/profile +// ========================================================================== + +// TestProfile_PublicVisibleToNonOwner 驗證 public model 非 owner 可看 profile, +// 且 owner.name 有帶出(Minor-1:profile join owner name,對齊 api §2)。 +func TestProfile_PublicVisibleToNonOwner(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) + repo.SetUserName("other", "Alice") // owner 顯示名 + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil)) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var p ProfileResponse + decodeData(t, w.Body.Bytes(), &p) + assert.Equal(t, model.AccessViewer, p.MyAccess) + assert.True(t, p.CanDownload) + assert.False(t, p.Owner.IsMe) + assert.Equal(t, "other", p.Owner.ID) + assert.Equal(t, "Alice", p.Owner.Name, "profile 應帶出 owner name(Minor-1)") +} + +// TestProfile_PrivateHiddenReturns404 驗證別人 private model → profile 回 404(防 enumeration)。 +func TestProfile_PrivateHiddenReturns404(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil)) + assert.Equal(t, http.StatusNotFound, w.Code, "無權限應回 404,不是 403") + assert.Contains(t, w.Body.String(), ErrCodeNotFound) +} + +// TestProfile_NonExistentReturns404Same 驗證不存在的 id 與無權限的 id 回相同 404(enumeration 防護)。 +func TestProfile_NonExistentReturns404Same(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate) + + wHidden := httptest.NewRecorder() + r.ServeHTTP(wHidden, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil)) + wMissing := httptest.NewRecorder() + r.ServeHTTP(wMissing, httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/profile", nil)) + + assert.Equal(t, wMissing.Code, wHidden.Code, "無權限與不存在應回相同 status") + // body 除了 request_id 外結構一致(都是 NOT_FOUND / model not found)。 + assert.Contains(t, wHidden.Body.String(), "model not found") + assert.Contains(t, wMissing.Body.String(), "model not found") +} + +// TestProfile_NoLeakInternalKeys 驗證 profile 不洩漏 storage_key / faa_object_key / owner email(SEC-3)。 +func TestProfile_NoLeakInternalKeys(t *testing.T) { + r, repo := newSharingFixture(t, "me") + m := seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic) + m.FAAObjectKey = "models/other/secret-object-key.nef" + m.FileChecksum = "sha256-secret" + require.NoError(t, repo.Save(context.Background(), m)) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil)) + body := w.Body.String() + assert.NotContains(t, body, "secret-object-key", "不應洩漏 faa_object_key") + assert.NotContains(t, body, "storage_key", "不應輸出 storage_key 欄") + assert.NotContains(t, body, m.StorageKey, "不應洩漏 storage_key 值") + assert.NotContains(t, body, "sha256-secret", "不應洩漏 file_checksum") + assert.NotContains(t, body, "email", "不應輸出 owner email 欄") +} + +// TestProfile_Preset 驗證 preset profile 任何登入 user 可看。 +func TestProfile_Preset(t *testing.T) { + r, _ := newSharingFixture(t, "me") + presets := model.PresetModels() + require.NotEmpty(t, presets) + presetID := presets[0].ID + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/"+presetID+"/profile", nil)) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + var p ProfileResponse + decodeData(t, w.Body.Bytes(), &p) + assert.Equal(t, model.VisibilityPublic, p.Visibility) + assert.True(t, p.CanDownload) +} + +// ========================================================================== +// PATCH /api/models/:id/visibility +// ========================================================================== + +// TestSetVisibility_OwnerOK 驗證 owner 可改 visibility。 +func TestSetVisibility_OwnerOK(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", + strings.NewReader(`{"visibility":"public"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + m, err := repo.Get(context.Background(), "m") + require.NoError(t, err) + assert.Equal(t, model.VisibilityPublic, m.Visibility) +} + +// TestSetVisibility_NonOwnerForbidden 驗證非 owner 改 visibility 回 403。 +func TestSetVisibility_NonOwnerForbidden(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", + strings.NewReader(`{"visibility":"public"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeForbidden) +} + +// TestSetVisibility_Invalid 驗證非法 visibility 值回 400。 +func TestSetVisibility_Invalid(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", + strings.NewReader(`{"visibility":"world"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestSetVisibility_TenantWithoutOrg 驗證 user 無 org 設 tenant 回 400。 +func TestSetVisibility_TenantWithoutOrg(t *testing.T) { + r, repo := newSharingFixture(t, "me") // injectStaticUserContext 不設 OrgID + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility", + strings.NewReader(`{"visibility":"tenant"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code, "無 org 設 tenant 應回 400") +} + +// TestSetVisibility_NotReadyConflict 驗證未 finalize 的 model 設公開回 409。 +func TestSetVisibility_NotReadyConflict(t *testing.T) { + r, repo := newSharingFixture(t, "me") + // pending model(UploadedAt=nil)。 + require.NoError(t, repo.Save(context.Background(), &model.Model{ + ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", + FileSize: 1, Source: model.SourceUploaded, + })) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/api/models/pending/visibility", + strings.NewReader(`{"visibility":"public"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusConflict, w.Code, "未 ready 設公開應回 409") +} + +// ========================================================================== +// shares CRUD(restricted 授權管理) +// ========================================================================== + +// TestShares_PutListDelete 驗證 owner 加/列/移除授權完整流程。 +func TestShares_PutListDelete(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + // PUT 加授權。 + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", + strings.NewReader(`{"grantee_user_id":"bob","role":"viewer"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + // GET 列授權。 + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m/shares", nil)) + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "bob") + + // DELETE 移除授權。 + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/bob", nil)) + require.Equal(t, http.StatusNoContent, w.Code) + + // 再列應為空。 + shares, err := repo.ListShares(context.Background(), "m") + require.NoError(t, err) + assert.Empty(t, shares) +} + +// TestShares_NonOwnerForbidden 驗證非 owner 不能管理授權。 +func TestShares_NonOwnerForbidden(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", + strings.NewReader(`{"grantee_user_id":"bob"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestShares_CannotShareToSelf 驗證不能分享給自己。 +func TestShares_CannotShareToSelf(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", + strings.NewReader(`{"grantee_user_id":"me"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestShares_InvalidRole 驗證非法 role 回 400。 +func TestShares_InvalidRole(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares", + strings.NewReader(`{"grantee_user_id":"bob","role":"admin"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestShares_DeleteNonExistent 驗證移除不存在的授權回 404。 +func TestShares_DeleteNonExistent(t *testing.T) { + r, repo := newSharingFixture(t, "me") + seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/ghost", nil)) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// ========================================================================== +// canAccessModel 單元測試(single source of truth) +// ========================================================================== + +// TestCanAccessModel_Levels 直接驗 canAccessModel 各級判斷。 +func TestCanAccessModel_Levels(t *testing.T) { + ctx := context.Background() + noShare := func(context.Context, string, string) (*model.ModelShare, error) { + return nil, model.ErrNotFound + } + + mPrivate := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPrivate} + mPublic := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPublic} + + // owner + assert.Equal(t, model.AccessOwner, + canAccessModelForTest(ctx, "owner", mPrivate, noShare)) + // 無關 user + private → none + assert.Equal(t, model.AccessNone, + canAccessModelForTest(ctx, "stranger", mPrivate, noShare)) + // public → viewer + assert.Equal(t, model.AccessViewer, + canAccessModelForTest(ctx, "stranger", mPublic, noShare)) + // share viewer + shareViewer := func(context.Context, string, string) (*model.ModelShare, error) { + return &model.ModelShare{Role: "viewer"}, nil + } + assert.Equal(t, model.AccessViewer, + canAccessModelForTest(ctx, "grantee", mPrivate, shareViewer)) + // share editor + shareEditor := func(context.Context, string, string) (*model.ModelShare, error) { + return &model.ModelShare{Role: "editor"}, nil + } + assert.Equal(t, model.AccessEditor, + canAccessModelForTest(ctx, "grantee", mPrivate, shareEditor)) + // tenant 但 user 無 org(OIDC 現況)→ none(安全預設,stub) + mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant} + assert.Equal(t, model.AccessNone, + canAccessModelForTest(ctx, "stranger", mTenant, noShare), + "tenant + 無 org → none(tenant stub)") +} diff --git a/visionA-backend/internal/db/migrate_0006_db_test.go b/visionA-backend/internal/db/migrate_0006_db_test.go new file mode 100644 index 0000000..8005009 --- /dev/null +++ b/visionA-backend/internal/db/migrate_0006_db_test.go @@ -0,0 +1,238 @@ +//go:build dbtest + +// Migration 0006(模型共享:visibility 欄 + model_shares 表 + index)的真 DB 整合測試。 +// +// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。 +// 執行: +// +// go test -tags=dbtest ./internal/db/... +// # 無本機 Docker 時,在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/db/... +// +// 對齊 migrations/0006_model_sharing.up.sql / .down.sql 與 feature-model-sharing-tdd.md §3: +// 1. apply:models 有 visibility 欄(NOT NULL DEFAULT 'private')、model_shares 表存在、 +// idx_model_shares_grantee / idx_models_public_active 存在、CHECK constraint 生效。 +// 2. 既有相容:apply 前既有 model → apply 後 visibility='private'(零行為改變)。 +// 3. model_shares FK / PK / role CHECK 生效。 +// 4. rollback 對稱:down 後 visibility 欄與 model_shares 表消失。 +// 5. re-apply 冪等:up→down→up 不報錯、結果一致。 +package db_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" +) + +// insertRawModel 直接寫入一筆 models(不經 repository),可控制 visibility(傳空用 DB DEFAULT)。 +// 回傳 model id。 +func insertRawModel(t *testing.T, tdb *testsupport.TestDB, ownerID, visibility string) string { + t.Helper() + id := uuid.NewString() + ctx := context.Background() + if visibility == "" { + // 不指定 visibility 欄,走 DB DEFAULT(驗既有相容)。 + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source) + VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded')`, + id, ownerID) + require.NoError(t, err, "insert raw model (default visibility)") + return id + } + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) + VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded', $3)`, + id, ownerID, visibility) + require.NoError(t, err, "insert raw model") + return id +} + +// TestMigrate0006_Apply 驗證 up 後 schema 到位(§1)。 +func TestMigrate0006_Apply(t *testing.T) { + tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0006) + + assert.True(t, colExists(t, tdb, "models", "visibility"), "models 應有 visibility 欄") + assert.True(t, tableExists(t, tdb, "model_shares"), "model_shares 表應存在") + + for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} { + assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx) + } + + // visibility 應 NOT NULL DEFAULT 'private'。 + ctx := context.Background() + var isNullable, colDefault string + err := tdb.Pool.QueryRow(ctx, + `SELECT is_nullable, COALESCE(column_default, '') FROM information_schema.columns + WHERE table_name = 'models' AND column_name = 'visibility'`).Scan(&isNullable, &colDefault) + require.NoError(t, err) + assert.Equal(t, "NO", isNullable, "visibility 應 NOT NULL") + assert.Contains(t, colDefault, "private", "visibility 預設應為 'private'") +} + +// TestMigrate0006_ExistingModelDefaultsPrivate 驗證既有 model 遷移後 visibility='private'(§2,關鍵相容性)。 +func TestMigrate0006_ExistingModelDefaultsPrivate(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + // 回退 0006 → models 無 visibility 欄。 + require.NoError(t, mg.Down(), "down 一步回到 0005") + require.False(t, colExists(t, tdb, "models", "visibility"), "down 後不應有 visibility 欄") + + owner := tdb.InsertUser(t, "", "") + // 在無 visibility 欄的狀態下塞既有 model。 + existingID := uuid.NewString() + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source) + VALUES ($1, $2, 'legacy', 'k', 1024, 'uploaded')`, + existingID, owner) + require.NoError(t, err) + + // 重新 up 0006。 + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0006") + + var vis string + err = tdb.Pool.QueryRow(ctx, `SELECT visibility FROM models WHERE id = $1`, existingID).Scan(&vis) + require.NoError(t, err) + assert.Equal(t, "private", vis, "既有 model 遷移後 visibility 應為 private(零行為改變)") +} + +// TestMigrate0006_VisibilityCheckConstraint 驗證非法 visibility 被 CHECK 擋下(§1)。 +func TestMigrate0006_VisibilityCheckConstraint(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + owner := tdb.InsertUser(t, "", "") + + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) + VALUES ($1, $2, 'bad', 'k', 1024, 'uploaded', 'world')`, + uuid.NewString(), owner) + assert.Error(t, err, "非法 visibility 'world' 應被 CHECK constraint 擋下") + + // 合法值可寫入。 + for _, v := range []string{"private", "tenant", "public"} { + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) + VALUES ($1, $2, 'ok', 'k', 1024, 'uploaded', $3)`, + uuid.NewString(), owner, v) + assert.NoError(t, err, "合法 visibility %q 應可寫入", v) + } +} + +// TestMigrate0006_ModelSharesConstraints 驗證 model_shares 的 PK / FK / role CHECK(§3)。 +func TestMigrate0006_ModelSharesConstraints(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + owner := tdb.InsertUser(t, "", "") + grantee := tdb.InsertUser(t, "", "") + modelID := insertRawModel(t, tdb, owner, "private") + + // 合法 share。 + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner) + require.NoError(t, err, "合法 share 應可寫入") + + // PK 重複(同 model + 同 grantee)→ 衝突。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'editor', $3)`, modelID, grantee, owner) + assert.Error(t, err, "重複 (model_id, grantee_user_id) 應違反 PK") + + // role CHECK:非法 role。 + grantee2 := tdb.InsertUser(t, "", "") + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'admin', $3)`, modelID, grantee2, owner) + assert.Error(t, err, "非法 role 'admin' 應被 CHECK 擋下") + + // FK:不存在的 model_id。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'viewer', $3)`, uuid.NewString(), grantee2, owner) + assert.Error(t, err, "不存在的 model_id 應違反 FK") + + // FK:不存在的 grantee_user_id。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'viewer', $3)`, modelID, uuid.NewString(), owner) + assert.Error(t, err, "不存在的 grantee_user_id 應違反 FK") +} + +// TestMigrate0006_ModelSharesCascade 驗證 model 硬刪時連帶清 share(ON DELETE CASCADE)。 +func TestMigrate0006_ModelSharesCascade(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + owner := tdb.InsertUser(t, "", "") + grantee := tdb.InsertUser(t, "", "") + modelID := insertRawModel(t, tdb, owner, "private") + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner) + require.NoError(t, err) + + // 硬刪 model(非軟刪)→ share 應連帶消失。 + _, err = tdb.Pool.Exec(ctx, `DELETE FROM models WHERE id = $1`, modelID) + require.NoError(t, err) + + var n int + err = tdb.Pool.QueryRow(ctx, + `SELECT count(*) FROM model_shares WHERE model_id = $1`, modelID).Scan(&n) + require.NoError(t, err) + assert.Equal(t, 0, n, "model 硬刪後 model_shares 應連帶清空(CASCADE)") +} + +// TestMigrate0006_RollbackSymmetry 驗證 down 對稱:visibility 欄與 model_shares 表消失(§4)。 +func TestMigrate0006_RollbackSymmetry(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + require.True(t, colExists(t, tdb, "models", "visibility"), "down 前 visibility 欄應存在") + require.True(t, tableExists(t, tdb, "model_shares"), "down 前 model_shares 表應存在") + + require.NoError(t, mg.Down(), "down 一步(回退 0006)") + + assert.False(t, colExists(t, tdb, "models", "visibility"), "down 後 visibility 欄應消失") + assert.False(t, tableExists(t, tdb, "model_shares"), "down 後 model_shares 表應消失") + for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} { + assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx) + } +} + +// TestMigrate0006_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§5)。 +func TestMigrate0006_ReApplyIdempotent(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + topVer, dirty, err := mg.Version() + require.NoError(t, err) + require.False(t, dirty) + + require.NoError(t, mg.Down(), "down 一步") + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up") + + ver, dirty, err := mg.Version() + require.NoError(t, err) + assert.False(t, dirty, "up→down→up 後不應 dirty") + assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新") + assert.True(t, colExists(t, tdb, "models", "visibility"), "重新 up 後 visibility 欄應再次存在") + assert.True(t, tableExists(t, tdb, "model_shares"), "重新 up 後 model_shares 表應再次存在") +} diff --git a/visionA-backend/internal/device/device.go b/visionA-backend/internal/device/device.go index 5686a55..18557fb 100644 --- a/visionA-backend/internal/device/device.go +++ b/visionA-backend/internal/device/device.go @@ -119,6 +119,16 @@ type Repository interface { // 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。 Save(ctx context.Context, d *Device) error + // SetRegistered 設定 / 清除註冊時間(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)。 + // + // - at != nil → 註冊(registered_at = *at)。 + // - at == nil → 取消註冊(registered_at = NULL),保留列(絕不軟刪 / 撤 token)。 + // + // 只作用於「未刪除、非 representative」的 device(縱深第三層,配合 handler 的 owner / + // representative / already-registered 檢查);不符則回 ErrNotFound。register 端的 + // already-registered 判斷由 handler 先擋(回 409),本方法不重複判。 + SetRegistered(ctx context.Context, id string, at *time.Time) error + // Delete 標記為軟刪除(設定 DeletedAt)。 Delete(ctx context.Context, id string) error } @@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error { return nil } +// SetRegistered 設定 / 清除某 device 的 registered_at(單欄翻轉)。 +// +// 語意對齊 PostgresRepository.SetRegistered:只作用於未刪除、非 representative 的 device, +// 不符(不存在 / 已軟刪 / representative)回 ErrNotFound(縱深第三層)。一律更新 UpdatedAt。 +// at==nil 清成未註冊(保留列),at!=nil 設為註冊時間。 +func (r *InMemoryRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + d, ok := r.devices[id] + if !ok || d.DeletedAt != nil || d.IsRepresentative { + return ErrNotFound + } + now := time.Now().UTC() + if at != nil { + t := at.UTC() + d.RegisteredAt = &t + } else { + d.RegisteredAt = nil + } + d.UpdatedAt = now + return nil +} + // GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、 // 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。 // diff --git a/visionA-backend/internal/device/inmemory_repository_test.go b/visionA-backend/internal/device/inmemory_repository_test.go index 42246f1..1b64edb 100644 --- a/visionA-backend/internal/device/inmemory_repository_test.go +++ b/visionA-backend/internal/device/inmemory_repository_test.go @@ -3,6 +3,7 @@ package device import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) { _, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent") assert.ErrorIs(t, err, ErrNotFound) } + +// --------------------------------------------------------------------------- +// SetRegistered(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3) +// --------------------------------------------------------------------------- + +// SetRegistered set → registered_at 有值;set nil → 清空。 +func TestInMemoryRepository_SetRegistered_SetAndClear(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"})) + + // 初始未註冊。 + got, err := r.Get(ctx, "d1") + require.NoError(t, err) + require.Nil(t, got.RegisteredAt) + + // set → 已註冊。 + now := time.Now().UTC() + require.NoError(t, r.SetRegistered(ctx, "d1", &now)) + got, err = r.Get(ctx, "d1") + require.NoError(t, err) + require.NotNil(t, got.RegisteredAt) + assert.True(t, now.Equal(*got.RegisteredAt)) + + // set nil → 退回未註冊,列仍在。 + require.NoError(t, r.SetRegistered(ctx, "d1", nil)) + got, err = r.Get(ctx, "d1") + require.NoError(t, err, "unregister 不刪列") + assert.Nil(t, got.RegisteredAt) +} + +// SetRegistered 冪等:對已 NULL 的列再 set nil → 成功(no-op)。 +func TestInMemoryRepository_SetRegistered_ClearIdempotent(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"})) + + require.NoError(t, r.SetRegistered(ctx, "d1", nil), "未註冊清 nil 應冪等成功") + got, _ := r.Get(ctx, "d1") + assert.Nil(t, got.RegisteredAt) +} + +// SetRegistered 對 representative device → ErrNotFound(縱深第三層)。 +func TestInMemoryRepository_SetRegistered_RejectsRepresentative(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: "u", AgentID: "ag", IsRepresentative: true})) + + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(ctx, "rep", &now), ErrNotFound, + "representative 不可註冊") +} + +// SetRegistered 對已軟刪 device → ErrNotFound。 +func TestInMemoryRepository_SetRegistered_RejectsDeleted(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"})) + require.NoError(t, r.Delete(ctx, "d1")) + + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(ctx, "d1", &now), ErrNotFound) +} + +// SetRegistered 對不存在 device → ErrNotFound。 +func TestInMemoryRepository_SetRegistered_NotFound(t *testing.T) { + r := NewInMemoryRepository() + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(context.Background(), "ghost", &now), ErrNotFound) +} diff --git a/visionA-backend/internal/device/postgres_repository.go b/visionA-backend/internal/device/postgres_repository.go index 3c90338..a188b69 100644 --- a/visionA-backend/internal/device/postgres_repository.go +++ b/visionA-backend/internal/device/postgres_repository.go @@ -31,6 +31,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device return nil } +// SetRegistered 設定 / 清除 registered_at(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3)。 +// +// 精準單欄 UPDATE(不走 Save 的全欄 upsert),避免「Get→改欄→Save 回去」的讀寫競態面: +// +// UPDATE devices SET registered_at = $2, updated_at = now() +// WHERE id = $1 AND deleted_at IS NULL AND is_representative = false +// +// WHERE 的 deleted_at IS NULL + is_representative = false 是縱深第三層(配合 handler 的 +// owner / representative / already-registered 檢查):對不存在 / 已軟刪 / representative 的 +// 列 RowsAffected()==0 → 回 ErrNotFound。 +// +// - register:at != nil(handler 已先擋 already-registered,這裡不重複判)。 +// - unregister:at == nil,清成 NULL;對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1 +// (WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」(冪等,TDD §4.1)。 +// +// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 token(TDD §1.2 紅線)。 +func (r *PostgresRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error { + const sql = `UPDATE devices + SET registered_at = $2, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL AND is_representative = false` + + tag, err := r.pool.Exec(ctx, sql, id, at) + if err != nil { + return fmt.Errorf("device: pg SetRegistered: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + // GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、 // 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。 // diff --git a/visionA-backend/internal/device/postgres_repository_db_test.go b/visionA-backend/internal/device/postgres_repository_db_test.go index 64d0c69..a059239 100644 --- a/visionA-backend/internal/device/postgres_repository_db_test.go +++ b/visionA-backend/internal/device/postgres_repository_db_test.go @@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) { _, err = r.List(ctx, owner) assert.Error(t, err, "已取消 ctx 的 List 應回 error") } + +// --------------------------------------------------------------------------- +// SetRegistered(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3 / WS-BE) +// --------------------------------------------------------------------------- + +// pgInsertAgent 建一筆 agent(滿足 devices.agent_id FK),回傳 agentID。 +func pgInsertAgent(t *testing.T, tdb *testsupport.TestDB, owner string) string { + t.Helper() + agentID := uuid.NewString() + _, err := tdb.Pool.Exec(context.Background(), + `INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`, + agentID, owner) + require.NoError(t, err) + return agentID +} + +// SetRegistered set → 已註冊;set nil → 退回未註冊(列保留)。 +func TestPG_SetRegistered_SetAndClear(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + agentID := pgInsertAgent(t, tdb, owner) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ + ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: agentID, + })) + + // 初始未註冊。 + got, err := r.Get(ctx, id) + require.NoError(t, err) + require.Nil(t, got.RegisteredAt) + + // set → 已註冊。 + now := time.Now().UTC().Truncate(time.Microsecond) + require.NoError(t, r.SetRegistered(ctx, id, &now)) + got, err = r.Get(ctx, id) + require.NoError(t, err) + require.NotNil(t, got.RegisteredAt, "register 後 registered_at 非 null") + assert.True(t, now.Equal(*got.RegisteredAt)) + + // set nil → 退回未註冊、列仍在(絕不軟刪)。 + require.NoError(t, r.SetRegistered(ctx, id, nil)) + got, err = r.Get(ctx, id) + require.NoError(t, err, "unregister 不軟刪,Get 應仍取得") + assert.Nil(t, got.RegisteredAt) + assert.Equal(t, 1, tdb.CountRows(t, "devices"), "unregister 不刪列,devices 仍 1 筆") +} + +// SetRegistered 冪等:對已 NULL 的列 set nil → RowsAffected 命中、成功 no-op。 +func TestPG_SetRegistered_ClearIdempotent(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + agentID := pgInsertAgent(t, tdb, owner) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ + ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x22222222", AgentID: agentID, + })) + + // 未註冊再清 → 成功(WHERE 命中、RowsAffected=1、UPDATE 到相同 NULL)。 + require.NoError(t, r.SetRegistered(ctx, id, nil), "未註冊清 nil 應冪等成功") + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Nil(t, got.RegisteredAt) +} + +// SetRegistered 對 representative device → RowsAffected=0 → ErrNotFound(WHERE is_representative=false)。 +func TestPG_SetRegistered_RejectsRepresentative(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + agentID := pgInsertAgent(t, tdb, owner) + + repID := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ + ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true, + })) + + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(ctx, repID, &now), ErrNotFound, + "representative device 應被 WHERE is_representative=false 擋成 ErrNotFound") +} + +// SetRegistered 對已軟刪 device → RowsAffected=0 → ErrNotFound(WHERE deleted_at IS NULL)。 +func TestPG_SetRegistered_RejectsDeleted(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + agentID := pgInsertAgent(t, tdb, owner) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ + ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x33333333", AgentID: agentID, + })) + require.NoError(t, r.Delete(ctx, id)) // 軟刪 + + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(ctx, id, &now), ErrNotFound, + "已軟刪 device 應回 ErrNotFound") +} + +// SetRegistered 對不存在 device → ErrNotFound。 +func TestPG_SetRegistered_NotFound(t *testing.T) { + r, _, _ := newPGRepo(t) + now := time.Now().UTC() + assert.ErrorIs(t, r.SetRegistered(context.Background(), uuid.NewString(), &now), ErrNotFound) +} diff --git a/visionA-backend/internal/model/inmemory_sharing_test.go b/visionA-backend/internal/model/inmemory_sharing_test.go new file mode 100644 index 0000000..1e4adc2 --- /dev/null +++ b/visionA-backend/internal/model/inmemory_sharing_test.go @@ -0,0 +1,152 @@ +package model + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readyModel 建一個 ready(UploadedAt 已設)的 model helper。 +func readyModel(id, owner, visibility string) *Model { + now := time.Now().UTC() + return &Model{ + ID: id, OwnerUserID: owner, Name: "m-" + id, + StorageKey: "k/" + id, FileSize: 1024, + Source: SourceUploaded, Visibility: visibility, UploadedAt: &now, + } +} + +// TestInMemory_SaveDefaultsVisibility 驗證 Save 未設 visibility 時預設 private。 +func TestInMemory_SaveDefaultsVisibility(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Model{ID: "m", OwnerUserID: "u", Name: "n", StorageKey: "k", Source: SourceUploaded})) + got, err := r.Get(ctx, "m") + require.NoError(t, err) + assert.Equal(t, VisibilityPrivate, got.Visibility, "未設 visibility 應預設 private") +} + +// TestInMemory_ShareCRUD 驗證 share 的 Upsert / Get / List / Delete。 +func TestInMemory_ShareCRUD(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "viewer", GrantedBy: "owner"})) + got, err := r.GetShare(ctx, "m", "bob") + require.NoError(t, err) + assert.Equal(t, "viewer", got.Role) + + // upsert 同 grantee → 更新 role。 + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "editor", GrantedBy: "owner"})) + got, err = r.GetShare(ctx, "m", "bob") + require.NoError(t, err) + assert.Equal(t, "editor", got.Role, "重複 upsert 應更新 role") + + // list + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "alice", Role: "viewer", GrantedBy: "owner"})) + shares, err := r.ListShares(ctx, "m") + require.NoError(t, err) + assert.Len(t, shares, 2) + + // delete + require.NoError(t, r.DeleteShare(ctx, "m", "bob")) + _, err = r.GetShare(ctx, "m", "bob") + assert.ErrorIs(t, err, ErrNotFound) + + // delete 不存在 → ErrNotFound + assert.ErrorIs(t, r.DeleteShare(ctx, "m", "ghost"), ErrNotFound) +} + +// TestInMemory_LibraryVisibility 驗證 Library predicate:我的 ∪ public ∪ shared,排除別人 private。 +func TestInMemory_LibraryVisibility(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + + require.NoError(t, r.Save(ctx, readyModel("mine", "me", VisibilityPrivate))) + require.NoError(t, r.Save(ctx, readyModel("otherPriv", "other", VisibilityPrivate))) + require.NoError(t, r.Save(ctx, readyModel("otherPub", "other", VisibilityPublic))) + require.NoError(t, r.Save(ctx, readyModel("otherShared", "other", VisibilityPrivate))) + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "otherShared", GranteeUserID: "me", Role: "viewer", GrantedBy: "other"})) + + items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100}) + require.NoError(t, err) + + ids := map[string]*LibraryItem{} + for _, it := range items { + ids[it.Model.ID] = it + } + assert.Contains(t, ids, "mine") + assert.Contains(t, ids, "otherPub") + assert.Contains(t, ids, "otherShared") + assert.NotContains(t, ids, "otherPriv", "別人 private 不應可見") + assert.True(t, ids["otherShared"].SharedWithMe) + assert.Equal(t, AccessOwner, ids["mine"].MyAccess) +} + +// TestInMemory_LibraryExcludesNotReady 驗證未 ready 的 model 不進 Library。 +func TestInMemory_LibraryExcludesNotReady(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + require.NoError(t, r.Save(ctx, &Model{ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", Source: SourceUploaded, Visibility: VisibilityPublic})) + + items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items) +} + +// TestInMemory_LibraryTenantStub 驗證 tenant 可見性:有 org 對應才命中(in-memory 用 SetUserOrg 模擬)。 +func TestInMemory_LibraryTenantStub(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + r.SetUserOrg("owner", "org-1") + r.SetUserOrg("teammate", "org-1") + r.SetUserOrg("outsider", "org-2") + require.NoError(t, r.Save(ctx, readyModel("tenantModel", "owner", VisibilityTenant))) + + // 同 org → 可見 + items, _, err := r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "org-1", Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "tenantModel", items[0].Model.ID) + + // 異 org → 不可見 + items, _, err = r.Library(ctx, LibraryQuery{UserID: "outsider", UserOrgID: "org-2", Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items, "異 org 不應看到 tenant model") + + // 無 org(OIDC 現況)→ 不可見(安全預設) + items, _, err = r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "", Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items, "無 org 不應命中 tenant") +} + +// TestInMemory_LibraryPaginationStable 驗證 cursor 分頁不重不漏。 +func TestInMemory_LibraryPaginationStable(t *testing.T) { + ctx := context.Background() + r := NewInMemoryRepository() + for i := 0; i < 5; i++ { + require.NoError(t, r.Save(ctx, readyModel(string(rune('a'+i)), "me", VisibilityPrivate))) + } + + seen := map[string]bool{} + var cursor *Cursor + for page := 0; page < 10; page++ { + items, hasMore, err := r.Library(ctx, LibraryQuery{ + UserID: "me", Limit: 2, Sort: "name", Order: "asc", Cursor: cursor, + }) + require.NoError(t, err) + for _, it := range items { + assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID) + seen[it.Model.ID] = true + } + if !hasMore { + break + } + last := items[len(items)-1].Model + cursor = &Cursor{ID: last.ID, SortValue: last.Name} + } + assert.Len(t, seen, 5, "所有 model 應被分頁走過一次") +} diff --git a/visionA-backend/internal/model/model.go b/visionA-backend/internal/model/model.go index cff3293..ba39a14 100644 --- a/visionA-backend/internal/model/model.go +++ b/visionA-backend/internal/model/model.go @@ -8,6 +8,8 @@ import ( "context" "errors" "fmt" + "sort" + "strings" "sync" "time" ) @@ -41,6 +43,54 @@ const ( SourcePreset Source = "preset" ) +// ========================================================================== +// Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1) +// ========================================================================== + +// Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。 +// 與 model_shares(點對點分享)正交。 +type Visibility = string + +const ( + // VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。 + VisibilityPrivate Visibility = "private" + // VisibilityTenant 同租戶(同 org_id)可見。 + // 依賴 users.org_id;OIDC 現況不帶 org claim(見 postgres_repository.go List 說明), + // 故目前 tenant 命中集合恆為空(安全預設)——schema/predicate 就緒,等 OIDC 補 org claim 即生效。 + VisibilityTenant Visibility = "tenant" + // VisibilityPublic 全平台已登入 user 可見。 + VisibilityPublic Visibility = "public" +) + +// IsValidVisibility 回報 v 是否為合法的 visibility 值(handler 驗 PATCH 輸入用)。 +func IsValidVisibility(v string) bool { + switch v { + case VisibilityPrivate, VisibilityTenant, VisibilityPublic: + return true + default: + return false + } +} + +// ========================================================================== +// AccessLevel 常數(可見性判斷的結果;對齊 TDD §6 SEC-2 / api §1 my_access) +// ========================================================================== + +// AccessLevel 是「當前 user 對某 model 的有效權限」。 +// 由 canAccessModel(single source of truth)計算,取最高。 +type AccessLevel = string + +const ( + // AccessNone 無可見性(不該看到此 model;enumeration 防護一律回 404)。 + AccessNone AccessLevel = "none" + // AccessViewer 可 list / get profile / download(visibility 命中或 share role=viewer)。 + AccessViewer AccessLevel = "viewer" + // AccessEditor 可改 metadata(share role=editor);含 viewer 全部權限。 + AccessEditor AccessLevel = "editor" + // AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。 + AccessOwner AccessLevel = "owner" +) + // ========================================================================== // Model struct(對齊 database.md §2.3) // ========================================================================== @@ -79,12 +129,75 @@ type Model struct { Source Source `json:"source"` SourceJobID string `json:"sourceJobId,omitempty"` + // Visibility 是廣播式公開對象(private / tenant / public,對齊 model_sharing 功能)。 + // 既有 / 新建 model 預設 VisibilityPrivate(DB DEFAULT 'private',零行為改變)。 + Visibility Visibility `json:"visibility"` + CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` UploadedAt *time.Time `json:"uploadedAt,omitempty"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } +// ========================================================================== +// ModelShare(點對點分享關聯;對齊 ADR-017 決策 3 B1 / model_shares 表) +// ========================================================================== + +// ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。 +type ModelShare struct { + ModelID string `json:"modelId"` + GranteeUserID string `json:"granteeUserId"` + Role string `json:"role"` // 'viewer' | 'editor' + GrantedBy string `json:"grantedBy"` + CreatedAt time.Time `json:"createdAt"` +} + +// LibraryItem 是共享庫列表的一列:Model + 該列相對於查詢 user 的存取資訊。 +// +// owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1)。 +// SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。 +type LibraryItem struct { + Model *Model + OwnerName string + OwnerOrgID string + SharedWithMe bool + MyAccess AccessLevel +} + +// LibraryQuery 是共享庫列表查詢的參數(對齊 api §1)。 +// +// Viewer 身份(UserID / UserOrgID)決定可見範圍;其餘為 filter / 排序 / cursor 分頁。 +type LibraryQuery struct { + // Viewer 身份(可見性 predicate 的 input)。 + UserID string + UserOrgID string // 空字串 → tenant 維度不命中任何 model(安全預設) + + // filter(皆可選,空值 = 不過濾該維度)。 + TargetChip string + Source Source + Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略 + Q string // 搜尋 name + description(ILIKE 包含) + // Owned:nil = 全部可見;true = 只我的;false = 只別人分享/公開給我的。 + Owned *bool + + // 排序 + 分頁。 + Sort string // 'created_at' | 'name' | 'file_size'(handler 已 validate) + Order string // 'asc' | 'desc' + Limit int // handler 已 clamp 到 1–100 + Cursor *Cursor // nil = 首頁 +} + +// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。 +// +// 由 handler 以不透明 base64 編碼給前端(見 api §1.3);repository 只吃解碼後的結構。 +type Cursor struct { + // SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定: + // created_at → RFC3339 時間字串;name → 字串;file_size → 十進位整數字串。 + SortValue string `json:"v"` + // ID 是上一頁最後一筆的 model id(tie-breaker,保證穩定分頁)。 + ID string `json:"id"` +} + // ========================================================================== // Filter / Repository // ========================================================================== @@ -103,6 +216,11 @@ type Repository interface { // Get 取得單一 Model;不存在或已刪除回 ErrNotFound。 Get(ctx context.Context, id string) (*Model, error) + // GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users,避免 N+1)。 + // 供 profile handler 顯示擁有者名(api §2 owner.name)。ownerName 可能為空(owner 未設 name)。 + // 不存在或已刪除回 ErrNotFound。 + GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err error) + // List 依 filter 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。 List(ctx context.Context, filter ListFilter) ([]*Model, error) @@ -111,6 +229,29 @@ type Repository interface { // Delete 軟刪除。 Delete(ctx context.Context, id string) error + + // ── 模型共享(model_sharing 功能新增)───────────────────────────────── + + // Library 依查詢 user 身份列出「可見」的 model(我的 ∪ public ∪ tenant同org ∪ 分享給我), + // 支援 filter / 排序 / cursor 分頁。回傳 items(已含 owner_name / my_access / shared_with_me) + // 與是否還有下一頁(hasMore)。preset 由 handler 層 union,不在此。 + // + // 只列 uploaded_at IS NOT NULL(ready)的 model;共享庫不列未 finalize 的。 + Library(ctx context.Context, q LibraryQuery) (items []*LibraryItem, hasMore bool, err error) + + // GetShare 取得 (modelID, granteeUserID) 的分享紀錄;不存在回 ErrNotFound。 + // 供 canAccessModel 單筆查「這個 model 有沒有分享給我」。 + GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) + + // ListShares 列出某 model 的所有分享紀錄(owner 檢視授權清單用)。 + ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) + + // UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。 + // 重複分享同一 grantee → 更新 role。 + UpsertShare(ctx context.Context, s *ModelShare) error + + // DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。 + DeleteShare(ctx context.Context, modelID, granteeUserID string) error } // ========================================================================== @@ -149,15 +290,41 @@ func (v *SizeValidator) Check(size int64) error { type InMemoryRepository struct { mu sync.RWMutex models map[string]*Model + // shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。 + shares map[string]map[string]*ModelShare + // orgs 記錄 userID → org_id,供 in-memory Library 判 tenant 可見性(測試注入用)。 + // production 走 Postgres 實作;in-memory 主要供 unit test,故用簡易注入而非 join users。 + orgs map[string]string + // names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。 + names map[string]string } // NewInMemoryRepository 建立一個空的記憶體 Repository。 func NewInMemoryRepository() *InMemoryRepository { return &InMemoryRepository{ models: make(map[string]*Model), + shares: make(map[string]map[string]*ModelShare), + orgs: make(map[string]string), + names: make(map[string]string), } } +// SetUserOrg 設定某 user 的 org_id(僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。 +// production 的 Postgres 實作直接 join users.org_id,不需此方法。 +func (r *InMemoryRepository) SetUserOrg(userID, orgID string) { + r.mu.Lock() + defer r.mu.Unlock() + r.orgs[userID] = orgID +} + +// SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name)。 +// production 的 Postgres 實作直接 join users.name,不需此方法。 +func (r *InMemoryRepository) SetUserName(userID, name string) { + r.mu.Lock() + defer r.mu.Unlock() + r.names[userID] = name +} + // Get 取得單一 Model。 func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) { r.mu.RLock() @@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) return &cp, nil } +// GetWithOwner 取單一 Model + owner 顯示名稱(in-memory 從 names map 取,測試以 SetUserName 注入)。 +func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + m, ok := r.models[id] + if !ok || m.DeletedAt != nil { + return nil, "", ErrNotFound + } + cp := *m + return &cp, r.names[m.OwnerUserID], nil +} + // List 依條件列出 Model。 func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) { r.mu.RLock() @@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error { } else if cp.CreatedAt.IsZero() { cp.CreatedAt = now } + // visibility 預設 private(對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。 + if cp.Visibility == "" { + cp.Visibility = VisibilityPrivate + } cp.UpdatedAt = now r.models[m.ID] = &cp return nil @@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error { return nil } +// --------------------------------------------------------------------------- +// InMemoryRepository — 模型共享方法 +// --------------------------------------------------------------------------- + +// Library 依查詢 user 身份列出可見 model(in-memory 實作,供 unit test)。 +// +// 可見性 predicate 對齊 TDD §4.1(我的 ∪ public ∪ tenant同org ∪ 分享給我)。 +// 排序 + cursor 分頁在記憶體內以全掃 + sort + 切片實作(in-memory 資料量小、不追求效能)。 +func (r *InMemoryRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + var matched []*LibraryItem + for _, m := range r.models { + if m.DeletedAt != nil || m.UploadedAt == nil { + continue // 共享庫只列未刪除且 ready 的 model + } + access := r.accessLevelLocked(q.UserID, q.UserOrgID, m) + if access == AccessNone { + continue + } + // filter:owned 維度。 + isMine := m.OwnerUserID == q.UserID + if q.Owned != nil { + if *q.Owned && !isMine { + continue + } + if !*q.Owned && isMine { + continue + } + } + if q.TargetChip != "" && m.TargetChip != q.TargetChip { + continue + } + if q.Source != "" && m.Source != q.Source { + continue + } + // visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內)。 + if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant { + if m.Visibility != q.Visibility { + continue + } + } + if q.Q != "" { + needle := strings.ToLower(q.Q) + if !strings.Contains(strings.ToLower(m.Name), needle) && + !strings.Contains(strings.ToLower(m.Description), needle) { + continue + } + } + _, shared := r.shareForLocked(m.ID, q.UserID) + cp := *m + matched = append(matched, &LibraryItem{ + Model: &cp, + OwnerName: r.names[m.OwnerUserID], // in-memory 從 names map 取(測試以 SetUserName 注入) + OwnerOrgID: r.orgs[m.OwnerUserID], + SharedWithMe: shared, + MyAccess: access, + }) + } + + sortLibraryItems(matched, q.Sort, q.Order) + + // cursor:找到游標對應 item 後的位置,取其後 limit+1 判 hasMore。 + start := 0 + if q.Cursor != nil { + for i, it := range matched { + if it.Model.ID == q.Cursor.ID { + start = i + 1 + break + } + } + } + limit := q.Limit + if limit <= 0 { + limit = 20 + } + end := start + limit + hasMore := false + if end < len(matched) { + hasMore = true + } + if start > len(matched) { + start = len(matched) + } + if end > len(matched) { + end = len(matched) + } + return matched[start:end], hasMore, nil +} + +// accessLevelLocked 計算 user 對 model 的 AccessLevel(呼叫端須持 r.mu)。 +// 對齊 canAccessModel 的 in-memory 版;順序:owner > share.role > visibility(viewer) > none。 +func (r *InMemoryRepository) accessLevelLocked(userID, userOrgID string, m *Model) AccessLevel { + if m.OwnerUserID == userID { + return AccessOwner + } + if s, ok := r.shareForLocked(m.ID, userID); ok { + if s.Role == "editor" { + return AccessEditor + } + return AccessViewer + } + if m.Visibility == VisibilityPublic { + return AccessViewer + } + if m.Visibility == VisibilityTenant && userOrgID != "" && r.orgs[m.OwnerUserID] == userOrgID { + return AccessViewer + } + return AccessNone +} + +// shareForLocked 回傳 (modelID, granteeUserID) 的 share(呼叫端須持 r.mu)。 +func (r *InMemoryRepository) shareForLocked(modelID, granteeUserID string) (*ModelShare, bool) { + byGrantee, ok := r.shares[modelID] + if !ok { + return nil, false + } + s, ok := byGrantee[granteeUserID] + return s, ok +} + +// GetShare 取得單筆 share;不存在回 ErrNotFound。 +func (r *InMemoryRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) { + r.mu.RLock() + defer r.mu.RUnlock() + s, ok := r.shareForLocked(modelID, granteeUserID) + if !ok { + return nil, ErrNotFound + } + cp := *s + return &cp, nil +} + +// ListShares 列出某 model 的所有 share。 +func (r *InMemoryRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*ModelShare, 0) + for _, s := range r.shares[modelID] { + cp := *s + out = append(out, &cp) + } + return out, nil +} + +// UpsertShare 新增 / 更新一筆 share(by PK)。 +func (r *InMemoryRepository) UpsertShare(ctx context.Context, s *ModelShare) error { + if s == nil || s.ModelID == "" || s.GranteeUserID == "" { + return errors.New("model: UpsertShare requires modelID and granteeUserID") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.shares[s.ModelID] == nil { + r.shares[s.ModelID] = make(map[string]*ModelShare) + } + cp := *s + if cp.CreatedAt.IsZero() { + cp.CreatedAt = time.Now().UTC() + } + if cp.Role == "" { + cp.Role = "viewer" + } + r.shares[s.ModelID][s.GranteeUserID] = &cp + return nil +} + +// DeleteShare 移除一筆 share;不存在回 ErrNotFound。 +func (r *InMemoryRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error { + r.mu.Lock() + defer r.mu.Unlock() + byGrantee, ok := r.shares[modelID] + if !ok { + return ErrNotFound + } + if _, ok := byGrantee[granteeUserID]; !ok { + return ErrNotFound + } + delete(byGrantee, granteeUserID) + return nil +} + +// sortLibraryItems 依 sort/order 就地排序 items;tie-breaker 一律用 model id 保證穩定。 +func sortLibraryItems(items []*LibraryItem, sortField, order string) { + desc := order != "asc" // 預設 desc + less := func(i, j int) bool { + a, b := items[i].Model, items[j].Model + var cmp int + switch sortField { + case "name": + cmp = strings.Compare(a.Name, b.Name) + case "file_size": + switch { + case a.FileSize < b.FileSize: + cmp = -1 + case a.FileSize > b.FileSize: + cmp = 1 + } + default: // created_at + switch { + case a.CreatedAt.Before(b.CreatedAt): + cmp = -1 + case a.CreatedAt.After(b.CreatedAt): + cmp = 1 + } + } + if cmp == 0 { + cmp = strings.Compare(a.ID, b.ID) // tie-breaker + } + if desc { + return cmp > 0 + } + return cmp < 0 + } + sort.SliceStable(items, less) +} + // 編譯時檢查:確保 InMemoryRepository 實作 Repository。 var _ Repository = (*InMemoryRepository)(nil) diff --git a/visionA-backend/internal/model/postgres_repository.go b/visionA-backend/internal/model/postgres_repository.go index 9c8cb16..5d8b264 100644 --- a/visionA-backend/internal/model/postgres_repository.go +++ b/visionA-backend/internal/model/postgres_repository.go @@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil) // modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。 const modelColumns = `id, owner_user_id, name, description, storage_key, file_size, file_checksum, faa_object_key, target_chip, input_shape, classes, framework, - source, source_job_id, created_at, updated_at, uploaded_at, deleted_at` + source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at` // Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。 func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) { @@ -139,15 +139,22 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error { // nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL), // 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。 + // visibility:空字串 → NULL 交給 COALESCE 落 'private'(對齊 DB DEFAULT + in-memory Save)。 + // 已設值(PATCH visibility / 呼叫端指定)則原樣寫入;CHECK constraint 擋非法值。 + var visibility any + if m.Visibility != "" { + visibility = string(m.Visibility) + } // else: 留 nil → COALESCE($15, 'private') + const q = ` INSERT INTO models ( id, owner_user_id, name, description, storage_key, file_size, file_checksum, faa_object_key, target_chip, input_shape, classes, framework, - source, source_job_id, created_at, updated_at, uploaded_at, deleted_at + source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, COALESCE($15, now()), now(), $16, $17 + $13, $14, COALESCE($15, 'private'), COALESCE($16, now()), now(), $17, $18 ) ON CONFLICT (id) DO UPDATE SET owner_user_id = EXCLUDED.owner_user_id, @@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error { framework = EXCLUDED.framework, source = EXCLUDED.source, source_job_id = EXCLUDED.source_job_id, + visibility = EXCLUDED.visibility, -- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。 created_at = CASE WHEN models.deleted_at IS NULL THEN models.created_at @@ -187,9 +195,10 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error { m.Framework, // $12 string(m.Source), // $13 nullableUUID(m.SourceJobID), // $14 - createdAt, // $15 - m.UploadedAt, // $16 - m.DeletedAt, // $17 + visibility, // $15 + createdAt, // $16 + m.UploadedAt, // $17 + m.DeletedAt, // $18 ) if err != nil { return fmt.Errorf("model: pg Save upsert: %w", err) @@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) { &framework, &m.Source, &sourceJobID, + &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, diff --git a/visionA-backend/internal/model/postgres_sharing.go b/visionA-backend/internal/model/postgres_sharing.go new file mode 100644 index 0000000..f2e1543 --- /dev/null +++ b/visionA-backend/internal/model/postgres_sharing.go @@ -0,0 +1,414 @@ +// postgres_sharing.go — PostgresRepository 的模型共享方法(Library 查詢 + model_shares CRUD)。 +// +// 對齊: +// - feature-model-sharing-tdd.md §4(可見性 predicate + query 形狀 + 效能考量) +// - api/api-model-sharing.md §1(library:cursor 分頁 / sort / filter / q) +// - adr-017-model-library-access.md 決策 3(model_shares schema) +// - migrations/0006_model_sharing.up.sql(visibility 欄 + model_shares 表 + index) +// +// 可見性 predicate(single source of truth 的 SQL 展開,對齊 TDD §4.1): +// +// 可見(model, user) = +// owner_user_id = :userID -- 我的 +// OR visibility = 'public' -- 全平台 +// OR (visibility = 'tenant' AND owner.org_id = :orgID -- 同租戶 +// AND :orgID <> '' AND owner.org_id IS NOT NULL) +// OR EXISTS (model_shares 命中 grantee=:userID) -- 分享給我 +// +// tenant 邊界(SEC-4):org_id 兩者皆非空才可能命中,空 org 一律不落 tenant 可見。 +// OIDC 現況不帶 org claim → :orgID 恆空 → tenant 集合恆空(安全預設),schema 就緒待 OIDC 補齊。 +package model + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// libraryColumns 是 Library 查詢的 SELECT 欄位(m.* + owner join + share 資訊)。 +// 順序必須與 scanLibraryItem 對齊。內部 key(storage_key / faa_object_key)雖 SELECT +// 出來供 domain Model 完整(download 端點需 FAAObjectKey),但 DTO 序列化層(api)不揭露。 +const libraryColumns = `m.id, m.owner_user_id, m.name, m.description, m.storage_key, m.file_size, + m.file_checksum, m.faa_object_key, m.target_chip, m.input_shape, m.classes, m.framework, + m.source, m.source_job_id, m.visibility, m.created_at, m.updated_at, m.uploaded_at, m.deleted_at, + COALESCE(u.name, '') AS owner_name, COALESCE(u.org_id::text, '') AS owner_org_id, + (s.grantee_user_id IS NOT NULL) AS shared_with_me, COALESCE(s.role, '') AS share_role` + +// Library 依查詢 user 身份列出可見 model(cursor 分頁)。見檔頭 predicate 說明。 +// +// query 形狀(對齊 TDD §4.2):單一 SELECT + JOIN users(取 owner.name / owner.org_id, +// 一次帶出避免 handler N+1)+ LEFT JOIN model_shares(取當前 user 的 share role / shared_with_me)。 +// filter / 排序 / keyset cursor 皆參數化拼接(無字串拼接使用者輸入)。 +func (r *PostgresRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) { + var args []any + arg := func(v any) string { // 追加參數並回傳其 $N placeholder + args = append(args, v) + return fmt.Sprintf("$%d", len(args)) + } + + userIDP := arg(q.UserID) + // orgID:空字串時仍傳入,SQL 內以 `<> ''` 判非空(tenant 邊界 SEC-4)。 + orgIDP := arg(q.UserOrgID) + + // 可見性 predicate(TDD §4.1)。model_shares 子查用 m.id 關聯(相關子查)。 + visPredicate := fmt.Sprintf(`( + m.owner_user_id = %[1]s + OR m.visibility = 'public' + OR (m.visibility = 'tenant' AND u.org_id IS NOT NULL AND u.org_id::text = %[2]s AND %[2]s <> '') + OR EXISTS (SELECT 1 FROM model_shares ms + WHERE ms.model_id = m.id AND ms.grantee_user_id = %[1]s) + )`, userIDP, orgIDP) + + conds := []string{ + "m.deleted_at IS NULL", + "m.uploaded_at IS NOT NULL", // 共享庫只列 ready + visPredicate, + } + + // filter:owned 維度。 + if q.Owned != nil { + if *q.Owned { + conds = append(conds, "m.owner_user_id = "+userIDP) + } else { + conds = append(conds, "m.owner_user_id <> "+userIDP) + } + } + if q.TargetChip != "" { + conds = append(conds, "m.target_chip = "+arg(q.TargetChip)) + } + if q.Source != "" { + conds = append(conds, "m.source = "+arg(q.Source)) + } + // visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內,忽略)。 + if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant { + conds = append(conds, "m.visibility = "+arg(q.Visibility)) + } + if q.Q != "" { + // ILIKE 包含式搜尋 name + description(TDD §5:第一階段 ILIKE,量大再上 FTS)。 + // 參數化 + 手動 escape LIKE 萬用字元,避免使用者輸入的 % / _ 改變語意。 + like := "%" + escapeLike(q.Q) + "%" + p := arg(like) + conds = append(conds, "(m.name ILIKE "+p+" ESCAPE '\\' OR COALESCE(m.description, '') ILIKE "+p+" ESCAPE '\\')") + } + + // 排序欄位白名單(handler 已 validate,這裡再次以 switch 白名單防禦,杜絕 SQL 注入)。 + sortCol := "m.created_at" + switch q.Sort { + case "name": + sortCol = "m.name" + case "file_size": + sortCol = "m.file_size" + case "created_at", "": + sortCol = "m.created_at" + } + dir := "DESC" + cmpOp := "<" + if q.Order == "asc" { + dir = "ASC" + cmpOp = ">" + } + + // keyset cursor:WHERE (sortCol, id) (cursorSortValue, cursorID)。 + // 用 row-value 比較保證與 ORDER BY (sortCol, id) 一致的穩定分頁。 + if q.Cursor != nil { + sv := castCursorValue(q.Sort, q.Cursor.SortValue) + svP := arg(sv.value) + idP := arg(q.Cursor.ID) + conds = append(conds, fmt.Sprintf("(%s, m.id) %s (%s%s, %s)", sortCol, cmpOp, svP, sv.cast, idP)) + } + + limit := q.Limit + if limit <= 0 { + limit = 20 + } + // 多取一筆判 hasMore。 + limitP := arg(limit + 1) + + query := `SELECT ` + libraryColumns + ` + FROM models m + JOIN users u ON u.id = m.owner_user_id + LEFT JOIN model_shares s ON s.model_id = m.id AND s.grantee_user_id = ` + userIDP + ` + WHERE ` + joinAnd(conds) + ` + ORDER BY ` + sortCol + ` ` + dir + `, m.id ` + dir + ` + LIMIT ` + limitP + + rows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, false, fmt.Errorf("model: pg Library query: %w", err) + } + defer rows.Close() + + items := make([]*LibraryItem, 0, limit) + for rows.Next() { + it, scanErr := scanLibraryItem(rows) + if scanErr != nil { + return nil, false, fmt.Errorf("model: pg Library scan: %w", scanErr) + } + items = append(items, it) + } + if err := rows.Err(); err != nil { + return nil, false, fmt.Errorf("model: pg Library rows: %w", err) + } + + hasMore := false + if len(items) > limit { + hasMore = true + items = items[:limit] + } + return items, hasMore, nil +} + +// cursorCast 描述 cursor 排序值的 SQL 值 + 型別 cast(讓 row-value 比較型別對齊欄位)。 +type cursorCast struct { + value string + cast string // 附加在 placeholder 後的 ::type,如 "::bigint" / "::timestamptz";name 為空 +} + +// castCursorValue 依 sort 欄位決定 cursor 值的型別 cast(避免 text 與欄位型別不符)。 +func castCursorValue(sortField, raw string) cursorCast { + switch sortField { + case "file_size": + return cursorCast{value: raw, cast: "::bigint"} + case "name": + return cursorCast{value: raw, cast: ""} + default: // created_at + return cursorCast{value: raw, cast: "::timestamptz"} + } +} + +// scanLibraryItem 掃出一列 LibraryItem。欄位順序須對齊 libraryColumns。 +func scanLibraryItem(row rowScanner) (*LibraryItem, error) { + var ( + m Model + description *string + fileChecksum *string + faaObjectKey *string + targetChip *string + inputShape []int32 + framework *string + sourceJobID *string + ownerName string + ownerOrgID string + sharedWithMe bool + shareRole string + ) + err := row.Scan( + &m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize, + &fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework, + &m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt, + &ownerName, &ownerOrgID, &sharedWithMe, &shareRole, + ) + if err != nil { + return nil, err + } + m.Description = derefString(description) + m.FileChecksum = derefString(fileChecksum) + m.FAAObjectKey = derefString(faaObjectKey) + m.TargetChip = derefString(targetChip) + m.Framework = derefString(framework) + m.SourceJobID = derefString(sourceJobID) + m.InputShape = toIntSlice(inputShape) + m.CreatedAt = m.CreatedAt.UTC() + m.UpdatedAt = m.UpdatedAt.UTC() + if m.UploadedAt != nil { + u := m.UploadedAt.UTC() + m.UploadedAt = &u + } + + // my_access:owner > share.role > public/tenant(viewer)。owner 由呼叫端已知(owner_user_id=userID), + // 但此處 Library 已用 predicate 過濾出可見列,故 access 一定 != none。 + // owner 的判斷在 handler(is_me),這裡計算「非 owner 情境」的 access;owner 情境 handler 覆寫為 owner。 + access := AccessViewer + if sharedWithMe && shareRole == "editor" { + access = AccessEditor + } + return &LibraryItem{ + Model: &m, + OwnerName: ownerName, + OwnerOrgID: ownerOrgID, + SharedWithMe: sharedWithMe, + MyAccess: access, + }, nil +} + +// GetWithOwner 取單一未刪除 Model + owner 顯示名稱(一次 JOIN users,供 profile 顯示 owner name)。 +// 不存在或已軟刪回 ErrNotFound。 +func (r *PostgresRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) { + q := `SELECT ` + prefixCols("m", modelColumns) + `, COALESCE(u.name, '') AS owner_name + FROM models m + JOIN users u ON u.id = m.owner_user_id + WHERE m.id = $1 AND m.deleted_at IS NULL` + + var ownerName string + row := r.pool.QueryRow(ctx, q, id) + m, err := scanModelWithExtra(row, &ownerName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", fmt.Errorf("model: pg GetWithOwner: %w", err) + } + return m, ownerName, nil +} + +// prefixCols 把 modelColumns 的每個裸欄名加上 table alias 前綴(`id` → `m.id`)。 +// modelColumns 是不含前綴的欄位清單;GetWithOwner 需 alias 以區分 join 的 users 欄。 +func prefixCols(alias, cols string) string { + parts := strings.Split(cols, ",") + for i, p := range parts { + parts[i] = alias + "." + strings.TrimSpace(p) + } + return strings.Join(parts, ", ") +} + +// scanModelWithExtra 掃出 *Model 後,再把 owner_name 掃進 extra(附加在 modelColumns 之後)。 +// 為此需重掃:pgx row 只能 Scan 一次,故這裡直接展開 model 欄位 + extra 一起 Scan。 +func scanModelWithExtra(row pgx.Row, ownerName *string) (*Model, error) { + var ( + m Model + description *string + fileChecksum *string + faaObjectKey *string + targetChip *string + inputShape []int32 + framework *string + sourceJobID *string + ) + err := row.Scan( + &m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize, + &fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework, + &m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt, + ownerName, + ) + if err != nil { + return nil, err + } + m.Description = derefString(description) + m.FileChecksum = derefString(fileChecksum) + m.FAAObjectKey = derefString(faaObjectKey) + m.TargetChip = derefString(targetChip) + m.Framework = derefString(framework) + m.SourceJobID = derefString(sourceJobID) + m.InputShape = toIntSlice(inputShape) + m.CreatedAt = m.CreatedAt.UTC() + m.UpdatedAt = m.UpdatedAt.UTC() + if m.UploadedAt != nil { + u := m.UploadedAt.UTC() + m.UploadedAt = &u + } + if m.DeletedAt != nil { + d := m.DeletedAt.UTC() + m.DeletedAt = &d + } + return &m, nil +} + +// --------------------------------------------------------------------------- +// model_shares CRUD +// --------------------------------------------------------------------------- + +// GetShare 取得 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。 +func (r *PostgresRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) { + const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at + FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2` + var s ModelShare + err := r.pool.QueryRow(ctx, q, modelID, granteeUserID). + Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("model: pg GetShare: %w", err) + } + s.CreatedAt = s.CreatedAt.UTC() + return &s, nil +} + +// ListShares 列出某 model 的所有分享(owner 檢視授權清單)。 +func (r *PostgresRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) { + const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at + FROM model_shares WHERE model_id = $1 ORDER BY created_at ASC` + rows, err := r.pool.Query(ctx, q, modelID) + if err != nil { + return nil, fmt.Errorf("model: pg ListShares: %w", err) + } + defer rows.Close() + out := make([]*ModelShare, 0) + for rows.Next() { + var s ModelShare + if err := rows.Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("model: pg ListShares scan: %w", err) + } + s.CreatedAt = s.CreatedAt.UTC() + out = append(out, &s) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("model: pg ListShares rows: %w", err) + } + return out, nil +} + +// UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。重複 grantee → 更新 role。 +func (r *PostgresRepository) UpsertShare(ctx context.Context, s *ModelShare) error { + if s == nil || s.ModelID == "" || s.GranteeUserID == "" { + return errors.New("model: UpsertShare requires modelID and granteeUserID") + } + role := s.Role + if role == "" { + role = "viewer" + } + const q = `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (model_id, grantee_user_id) DO UPDATE SET + role = EXCLUDED.role, granted_by = EXCLUDED.granted_by` + if _, err := r.pool.Exec(ctx, q, s.ModelID, s.GranteeUserID, role, s.GrantedBy); err != nil { + return fmt.Errorf("model: pg UpsertShare: %w", err) + } + return nil +} + +// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。 +func (r *PostgresRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error { + const q = `DELETE FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2` + tag, err := r.pool.Exec(ctx, q, modelID, granteeUserID) + if err != nil { + return fmt.Errorf("model: pg DeleteShare: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// --------------------------------------------------------------------------- +// helper +// --------------------------------------------------------------------------- + +// joinAnd 以 " AND " 串接 WHERE 條件。 +func joinAnd(conds []string) string { + out := "" + for i, c := range conds { + if i > 0 { + out += " AND " + } + out += c + } + return out +} + +// escapeLike escape LIKE / ILIKE 的萬用字元(% _ \),避免使用者輸入改變 pattern 語意。 +// 搭配查詢端的 `ESCAPE '\'`。 +func escapeLike(s string) string { + var b []byte + for i := 0; i < len(s); i++ { + c := s[i] + if c == '%' || c == '_' || c == '\\' { + b = append(b, '\\') + } + b = append(b, c) + } + return string(b) +} diff --git a/visionA-backend/internal/model/postgres_sharing_db_test.go b/visionA-backend/internal/model/postgres_sharing_db_test.go new file mode 100644 index 0000000..991e09a --- /dev/null +++ b/visionA-backend/internal/model/postgres_sharing_db_test.go @@ -0,0 +1,295 @@ +//go:build dbtest + +// PostgresRepository 模型共享方法(Library 查詢 + model_shares CRUD)的真 DB 整合測試。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 執行: +// +// go test -tags=dbtest ./internal/model/... +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/model/... +// +// 涵蓋:可見性 predicate(我的 ∪ public ∪ tenant同org ∪ shared)、enumeration 排除、 +// filter / 搜尋 / cursor 分頁、share CRUD、tenant 邊界(空 org 不落 tenant)。 +package model + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// insertUserWithOrg 寫入一筆帶 org_id 的 user,回傳 user id。org 為空時 org_id=NULL。 +func insertUserWithOrg(t *testing.T, tdb *testsupport.TestDB, org string) string { + t.Helper() + id := uuid.NewString() + ctx := context.Background() + if org == "" { + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO users (id, email) VALUES ($1, $2)`, id, id+"@t.local") + require.NoError(t, err) + return id + } + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO users (id, email, org_id) VALUES ($1, $2, $3)`, id, id+"@t.local", org) + require.NoError(t, err) + return id +} + +// saveReady 存一個 ready model(指定 owner + visibility),回傳其 id。 +func saveReady(t *testing.T, r *PostgresRepository, owner, visibility, name string) string { + t.Helper() + now := time.Now().UTC() + id := uuid.NewString() + require.NoError(t, r.Save(context.Background(), &Model{ + ID: id, OwnerUserID: owner, Name: name, + StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024, + Source: SourceUploaded, Visibility: visibility, UploadedAt: &now, + })) + return id +} + +// TestPGShare_LibraryVisibility 驗證 Library predicate(我的 ∪ public ∪ shared,排除別人 private)。 +func TestPGShare_LibraryVisibility(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + me := insertUserWithOrg(t, tdb, "") + other := insertUserWithOrg(t, tdb, "") + + mine := saveReady(t, r, me, VisibilityPrivate, "mine") + saveReady(t, r, other, VisibilityPrivate, "otherPriv") + pub := saveReady(t, r, other, VisibilityPublic, "otherPub") + shared := saveReady(t, r, other, VisibilityPrivate, "otherShared") + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: shared, GranteeUserID: me, Role: "viewer", GrantedBy: other})) + + items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100}) + require.NoError(t, err) + + got := map[string]*LibraryItem{} + for _, it := range items { + got[it.Model.ID] = it + } + assert.Contains(t, got, mine) + assert.Contains(t, got, pub) + assert.Contains(t, got, shared) + assert.Len(t, got, 3, "別人的 private 不應出現") + assert.True(t, got[shared].SharedWithMe) + assert.Equal(t, "viewer", got[shared].MyAccess) +} + +// TestPGShare_LibraryTenantBoundary 驗證 tenant 可見性:同 org 命中、異 org / 空 org 不命中(SEC-4)。 +func TestPGShare_LibraryTenantBoundary(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + // org_id 是 UUID 欄,用真 UUID(不是 'org-1' 這種字面)。 + org1 := uuid.NewString() + org2 := uuid.NewString() + orgOwner := insertUserWithOrg(t, tdb, org1) + teammate := insertUserWithOrg(t, tdb, org1) + outsider := insertUserWithOrg(t, tdb, org2) + noOrg := insertUserWithOrg(t, tdb, "") + + tenantModel := saveReady(t, r, orgOwner, VisibilityTenant, "tenant") + + // 同 org → 可見。UserOrgID 傳 org_id 的 text 形式(對齊 UserContext.OrgID 為字串)。 + items, _, err := r.Library(ctx, LibraryQuery{UserID: teammate, UserOrgID: org1, Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, tenantModel, items[0].Model.ID) + + // 異 org → 不可見。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: outsider, UserOrgID: org2, Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items, "異 org 不應看到 tenant model") + + // 空 org(OIDC 現況)→ 不可見(安全預設,即使 model 是 tenant)。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: noOrg, UserOrgID: "", Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items, "空 org 不應落 tenant 可見(SEC-4)") +} + +// TestPGShare_LibraryFilters 驗證 filter(owned / target_chip / source / visibility / q)。 +func TestPGShare_LibraryFilters(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + me := insertUserWithOrg(t, tdb, "") + other := insertUserWithOrg(t, tdb, "") + mine := saveReady(t, r, me, VisibilityPrivate, "yolo-mine") + pub := saveReady(t, r, other, VisibilityPublic, "resnet-pub") + + // owned=true → 只我的。 + items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(true), Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, mine, items[0].Model.ID) + + // owned=false → 只別人。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(false), Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, pub, items[0].Model.ID) + + // visibility=public。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Visibility: VisibilityPublic, Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, pub, items[0].Model.ID) + + // q=yolo(搜尋 name)。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "yolo", Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, mine, items[0].Model.ID) + + // q 含 LIKE 萬用字元應被 escape(不 match 全部)。 + items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "%", Limit: 100}) + require.NoError(t, err) + assert.Empty(t, items, "字面 '%' 不應 match 任何 model(萬用字元已 escape)") +} + +// TestPGShare_LibraryCursorPagination 驗證 cursor 分頁不重不漏(真 DB keyset)。 +func TestPGShare_LibraryCursorPagination(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + me := insertUserWithOrg(t, tdb, "") + for i := 0; i < 7; i++ { + saveReady(t, r, me, VisibilityPrivate, "m"+string(rune('a'+i))) + } + + seen := map[string]bool{} + var cursor *Cursor + for page := 0; page < 20; page++ { + items, hasMore, err := r.Library(ctx, LibraryQuery{ + UserID: me, Limit: 3, Sort: "name", Order: "asc", Cursor: cursor, + }) + require.NoError(t, err) + for _, it := range items { + assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID) + seen[it.Model.ID] = true + } + if !hasMore { + break + } + require.NotEmpty(t, items) + last := items[len(items)-1].Model + cursor = &Cursor{ID: last.ID, SortValue: last.Name} + } + assert.Len(t, seen, 7, "所有 model 應被分頁完整走過一次") +} + +// TestPGShare_ShareCRUD 驗證 share Upsert / Get / List / Delete(真 DB)。 +func TestPGShare_ShareCRUD(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + owner := insertUserWithOrg(t, tdb, "") + bob := insertUserWithOrg(t, tdb, "") + alice := insertUserWithOrg(t, tdb, "") + m := saveReady(t, r, owner, VisibilityPrivate, "m") + + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "viewer", GrantedBy: owner})) + got, err := r.GetShare(ctx, m, bob) + require.NoError(t, err) + assert.Equal(t, "viewer", got.Role) + + // upsert 更新 role。 + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "editor", GrantedBy: owner})) + got, err = r.GetShare(ctx, m, bob) + require.NoError(t, err) + assert.Equal(t, "editor", got.Role) + + require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: alice, Role: "viewer", GrantedBy: owner})) + shares, err := r.ListShares(ctx, m) + require.NoError(t, err) + assert.Len(t, shares, 2) + + require.NoError(t, r.DeleteShare(ctx, m, bob)) + _, err = r.GetShare(ctx, m, bob) + assert.ErrorIs(t, err, ErrNotFound) + assert.ErrorIs(t, r.DeleteShare(ctx, m, uuid.NewString()), ErrNotFound) +} + +// TestPGShare_LibraryExcludesSoftDeletedAndPending 驗證軟刪 / 未 ready 的 model 不進 Library。 +func TestPGShare_LibraryExcludesSoftDeletedAndPending(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + me := insertUserWithOrg(t, tdb, "") + // pending(無 UploadedAt)。 + pendingID := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ + ID: pendingID, OwnerUserID: me, Name: "pending", StorageKey: "k", + FileSize: 1, Source: SourceUploaded, Visibility: VisibilityPublic, + })) + // ready 然後軟刪。 + deleted := saveReady(t, r, me, VisibilityPublic, "deleted") + require.NoError(t, r.Delete(ctx, deleted)) + // 正常 ready。 + ok := saveReady(t, r, me, VisibilityPrivate, "ok") + + items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100}) + require.NoError(t, err) + require.Len(t, items, 1, "只應列正常 ready 的 model") + assert.Equal(t, ok, items[0].Model.ID) +} + +// TestPGShare_GetWithOwner 驗證 GetWithOwner join 出 owner name(Minor-1,真 DB)。 +func TestPGShare_GetWithOwner(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "model_shares", "models", "users") + r := NewPostgresRepository(tdb.Pool) + ctx := context.Background() + + // 建帶 name 的 owner。 + ownerID := uuid.NewString() + _, err := tdb.Pool.Exec(ctx, + `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`, + ownerID, ownerID+"@t.local", "Alice") + require.NoError(t, err) + + modelID := saveReady(t, r, ownerID, VisibilityPublic, "m") + + m, ownerName, err := r.GetWithOwner(ctx, modelID) + require.NoError(t, err) + assert.Equal(t, modelID, m.ID) + assert.Equal(t, VisibilityPublic, m.Visibility) + assert.Equal(t, "Alice", ownerName, "GetWithOwner 應 join 出 owner name") + + // owner 無 name → 空字串(COALESCE)。 + noNameOwner := insertUserWithOrg(t, tdb, "") + m2 := saveReady(t, r, noNameOwner, VisibilityPrivate, "m2") + _, ownerName2, err := r.GetWithOwner(ctx, m2) + require.NoError(t, err) + assert.Equal(t, "", ownerName2, "owner 無 name 時 owner_name 應為空") + + // 不存在 / 已軟刪 → ErrNotFound。 + _, _, err = r.GetWithOwner(ctx, uuid.NewString()) + assert.ErrorIs(t, err, ErrNotFound) + require.NoError(t, r.Delete(ctx, modelID)) + _, _, err = r.GetWithOwner(ctx, modelID) + assert.ErrorIs(t, err, ErrNotFound, "已軟刪應回 ErrNotFound") +} + +func boolPtr(b bool) *bool { return &b } diff --git a/visionA-backend/internal/model/presets.go b/visionA-backend/internal/model/presets.go index 33a9b90..7678c32 100644 --- a/visionA-backend/internal/model/presets.go +++ b/visionA-backend/internal/model/presets.go @@ -185,5 +185,8 @@ func clonePreset(m *Model) *Model { t := *m.UploadedAt cp.UploadedAt = &t } + // preset 是公用模型,語意等同全平台可見(visibility=public)。 + // 在此統一標記,preset 宣告區不必逐筆設 Visibility。 + cp.Visibility = VisibilityPublic return &cp } diff --git a/visionA-backend/migrations/0006_model_sharing.down.sql b/visionA-backend/migrations/0006_model_sharing.down.sql new file mode 100644 index 0000000..07e5a29 --- /dev/null +++ b/visionA-backend/migrations/0006_model_sharing.down.sql @@ -0,0 +1,17 @@ +-- 0006_model_sharing.down.sql +-- +-- 反向 0006:對稱移除 model_shares 表、models.visibility 欄與相關 index / constraint。 +-- 順序:先刪依賴 visibility 的 partial index → 刪 model_shares 表(其 index 隨表 DROP 自動移除) +-- → 刪 models 的 constraint + 欄位。 + +-- (3) 共享庫查詢 index。 +DROP INDEX IF EXISTS idx_models_public_active; + +-- (2) model_shares 表(idx_model_shares_grantee 隨表 DROP 自動移除)。 +DROP TABLE IF EXISTS model_shares; + +-- (1) models.visibility 欄與其 CHECK constraint。 +-- 先 DROP CONSTRAINT 再 DROP COLUMN(DROP COLUMN 也會連帶移除 constraint, +-- 此處顯式先移以求對稱清楚)。 +ALTER TABLE models DROP CONSTRAINT IF EXISTS chk_models_visibility; +ALTER TABLE models DROP COLUMN IF EXISTS visibility; diff --git a/visionA-backend/migrations/0006_model_sharing.up.sql b/visionA-backend/migrations/0006_model_sharing.up.sql new file mode 100644 index 0000000..b996729 --- /dev/null +++ b/visionA-backend/migrations/0006_model_sharing.up.sql @@ -0,0 +1,49 @@ +-- 0006_model_sharing.up.sql +-- +-- 模型共享(Model Sharing)L 級新功能。在既有 owner-only 模型庫上,疊加兩個正交維度: +-- (1) visibility 廣播欄(private / tenant / public)— models 表加 enum 欄。 +-- (2) model_shares 點對點分享表(ADR-017 決策 3 B1)— 分享給特定 user。 +-- +-- 對齊:docs/autoflow/04-architecture/feature-model-sharing-tdd.md §3、 +-- docs/autoflow/04-architecture/api/api-model-sharing.md、 +-- docs/autoflow/04-architecture/adr/adr-017-model-library-access.md 決策 3。 +-- +-- 環境事實(與 0001–0005 相同,已驗證):PostgreSQL 14.23,gen_random_uuid() 內建可直接用。 +-- +-- ★關鍵相容性:models.visibility DEFAULT 'private' → 既有所有 model 遷移後維持 owner-only +-- 語意,零行為改變。使用者要主動 PATCH visibility 才會公開。 + +-- ── (1) models 加 visibility 欄(廣播式公開對象)───────────────────────────── +-- 'private'(僅擁有者,= 現況預設)| 'tenant'(同租戶可見)| 'public'(全平台可見) +-- 全部既有 row 加欄後為 'private'(DEFAULT),語意完全等同遷移前的 owner-only。 +ALTER TABLE models ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; +ALTER TABLE models ADD CONSTRAINT chk_models_visibility + CHECK (visibility IN ('private', 'tenant', 'public')); + +-- ── (2) model_shares 表(點對點分享,ADR-017 決策 3 B1,本功能沿用不重造)────── +-- role:'viewer'(可 list/get/download)| 'editor'(可改 metadata;本期讀取端用,寫入權後續)。 +-- PK (model_id, grantee_user_id):同一 model 對同一 grantee 只有一筆分享(重複分享 = upsert)。 +-- FK ON DELETE CASCADE:model 硬刪時連帶清 share(雖然本系統 model 為軟刪,CASCADE 為防禦性 +-- 一致——若未來真硬刪不留孤兒列;軟刪時 share 保留,由查詢端 join models.deleted_at 過濾)。 +CREATE TABLE model_shares ( + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + grantee_user_id UUID NOT NULL REFERENCES users(id), + role TEXT NOT NULL DEFAULT 'viewer', + granted_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (model_id, grantee_user_id), + CONSTRAINT chk_model_shares_role CHECK (role IN ('viewer', 'editor')) +); + +-- grantee 反查(共享庫「分享給我」predicate 的 EXISTS 子查走此 index)。 +CREATE INDEX idx_model_shares_grantee ON model_shares (grantee_user_id); + +-- ── (3) 共享庫查詢用 index ─────────────────────────────────────────────────── +-- public 全平台可見列表:high-selectivity partial index(沿用既有 models index 的 +-- `WHERE deleted_at IS NULL` 慣例)。只索引 public 且未刪除且已上傳(ready)的 model, +-- 共享庫預設按 created_at DESC 排序、此 index 直接覆蓋該掃描。 +CREATE INDEX idx_models_public_active ON models (created_at DESC) + WHERE deleted_at IS NULL AND visibility = 'public' AND uploaded_at IS NOT NULL; + +-- tenant 可見需 join users 取 owner.org_id;users 主鍵 join 成本低, +-- owner 維度沿用既有 idx_models_owner_active,不另建。