feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-08-02 16:29:50 +08:00
parent 17134e8eae
commit 47a1d4d0ef
21 changed files with 3688 additions and 17 deletions

View File

@ -0,0 +1,216 @@
// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
//
// 「註冊」語意軸feature-device-mgmt-tdd §3 / §4api/api-device-mgmt.md
// - register把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
// - unregister把 registered_at 清成 NULL退回未註冊**保留裝置列**。
//
// 🔴 與 unpair 完全不同TDD §1 紅線unpair 軟刪整台 + cascade 撤 tokendevice 從清單
// 消失unregister 只清單欄 registered_atdevice 仍在清單、顯示為未註冊)。兩端點各走各的,
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token也**不改** devicesUnpairHandler。
//
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent用 UUID `:id` 識別
// (對齊 ADR-018 FE-ADB 操作用 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 → 500auth middleware 沒配好,不可 fallthrough
// 2. :id 空 → 400 VALIDATION_FAILED
// 3. Get deviceErrNotFound → 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.3representative 是 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→ 已註冊檢查6409 ALREADY_REGISTERED
// → SetRegistered(now())7→ 200 + 更新後 DeviceListItemregistered_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.2registered_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 + 更新後 DeviceListItemregistered_at=null
//
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 sessionTDD §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 仍為 1WHERE 命中),避免使用者連點兩次第二次報錯。
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)
}

View File

@ -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 建 routerInMemory repo + 無 sessionuser 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 解析成功回應的 dataDeviceListItem 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 FORBIDDENIDOR 主防線)。
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 → 500auth 沒配好不可 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=nulldevice 仍在 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 應冪等回 200body=%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)
}

View File

@ -41,6 +41,11 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// Unpair雛形實作軟刪 DeviceRepo + CloseSession // Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps)) g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
// 註冊軸feature-device-mgmt P0純雲端 DB 操作、UUID :id、不 proxy
// registerregistered_at NULL→now()unregister清 registered_at保留列與 unpair 分開)。
g.POST("/devices/:id/register", devicesRegisterHandler(deps))
g.POST("/devices/:id/unregister", devicesUnregisterHandler(deps))
// ADR-019 WP-5localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打 // ADR-019 WP-5localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
// local-agent issue-token。契約 path 為 /api/devices/:serial/local-upload-ticket // local-agent issue-token。契約 path 為 /api/devices/:serial/local-upload-ticket
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial // 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial

View File

@ -21,6 +21,14 @@ const (
ErrCodeInvalidSignature = "INVALID_SIGNATURE" ErrCodeInvalidSignature = "INVALID_SIGNATURE"
// ErrCodeConflict 對齊 HTTP 409unique 約束衝突 — 同 owner+serial 重複註冊)。 // ErrCodeConflict 對齊 HTTP 409unique 約束衝突 — 同 owner+serial 重複註冊)。
ErrCodeConflict = "CONFLICT" ErrCodeConflict = "CONFLICT"
// ErrCodeAlreadyRegistered 對齊 HTTP 409對已註冊registered_at 非 null的 device
// 再次呼叫 register。前端據此顯示「此裝置已註冊」並 refetchfeature-device-mgmt-tdd §3.2)。
ErrCodeAlreadyRegistered = "ALREADY_REGISTERED"
// ErrCodeRepresentativeDevice 對齊 HTTP 409對 representative deviceagent 連線佔位、
// 非真實 USB呼叫 register/unregister。註冊語意只適用真實 USB device
// feature-device-mgmt-tdd §7.3)。與 ALREADY_REGISTERED 分開,讓 FE/TEST 能區分
// 「已註冊」與「不可註冊的裝置類型」兩種 409。
ErrCodeRepresentativeDevice = "REPRESENTATIVE_DEVICE"
// ErrCodeServiceUnavailable 對齊 HTTP 503。 // ErrCodeServiceUnavailable 對齊 HTTP 503。
// DB 接入塊 5.4 fail-fast 策略PG 連線失敗 / context 逾時 → 503讓 load balancer 知道 // DB 接入塊 5.4 fail-fast 策略PG 連線失敗 / context 逾時 → 503讓 load balancer 知道
// 這台不健康,而非回假資料或 500500 會誤導為「程式 bug」503 才是「依賴暫時不可用」)。 // 這台不健康,而非回假資料或 500500 會誤導為「程式 bug」503 才是「依賴暫時不可用」)。

View File

@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
// Phase 0.9 模型庫 model 直連 FAA 下載ADR-017 (a))。 // Phase 0.9 模型庫 model 直連 FAA 下載ADR-017 (a))。
g.GET("/models/:id/download", modelsDownloadHandler(deps)) g.GET("/models/:id/download", modelsDownloadHandler(deps))
// 模型共享library / profile / visibility / shares
registerModelSharingRoutes(g, deps)
// load-to-device 雛形先 stub完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent // load-to-device 雛形先 stub完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent
g.POST("/models/:id/load-to-device", func(c *gin.Context) { g.POST("/models/:id/load-to-device", func(c *gin.Context) {
WriteNotImplemented(c, "models.load-to-device — pending Phase 1") 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 // snake_case 對齊本 DTO 既有慣例target_chip / file_size / created_at
// 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。 // 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。
// omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。 // omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
InputShape []int `json:"input_shape,omitempty"` InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"` Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"` Framework string `json:"framework,omitempty"`
// Visibility模型共享功能新增。既有前端未讀此欄不受影響加欄相容
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
Visibility string `json:"visibility,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
UploadedAt *time.Time `json:"uploaded_at,omitempty"` UploadedAt *time.Time `json:"uploaded_at,omitempty"`
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
InputShape: m.InputShape, InputShape: m.InputShape,
Classes: m.Classes, Classes: m.Classes,
Framework: m.Framework, Framework: m.Framework,
Visibility: m.Visibility,
CreatedAt: m.CreatedAt, CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt, UpdatedAt: m.UpdatedAt,
UploadedAt: m.UploadedAt, UploadedAt: m.UploadedAt,
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
WriteDBError(c, deps.Logger, "get model", err) WriteDBError(c, deps.Logger, "get model", err)
return return
} }
// 第一階段 owner-onlyB 分享後續階段);非 owner 回 403。 // 模型共享放寬owner-only → 共享權限檢查TDD §5 download 連帶變更)。
if m.OwnerUserID != userID { // 與 profile 可見性共用同一 canAccessModelsingle source of truth杜絕邏輯漂移SEC-2
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil) // 不命中回 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 return
} }

View File

@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
assert.Contains(t, w.Body.String(), ErrCodeNotFound) assert.Contains(t, w.Body.String(), ErrCodeNotFound)
} }
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) { // TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變TDD §5 download 放寬):
// 非 owner 且無任何可見性private model下載回 404不是 403——防 enumerationSEC-1
// 與 profile 的 canAccessModel 判斷一致single source of truth
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
iss := &fakeIssuer{token: "fdt_x"} 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") r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef") 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) req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404防 enumeration")
assert.Contains(t, w.Body.String(), ErrCodeForbidden) assert.Contains(t, w.Body.String(), ErrCodeNotFound)
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner") 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 把 modelprivate分享給 demo-userviewer
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)
} }
// ========================================================================== // ==========================================================================

View File

@ -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
//
// 核心安全設計:所有可見性判斷走唯一的 canAccessModelsingle source of truth避免
// profile / download 兩處邏輯漂移TDD §6 SEC-2enumeration 防護一律回 404SEC-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. ownerm.OwnerUserID == uc.UserID→ AccessOwner
// 2. share 命中 → editor / viewer依 share.role
// 3. visibility=public → viewer
// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewerSEC-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 claimmiddleware 未填 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 是共享庫列表的一列 DTOapi §1
//
// owner 只揭露 id/name/is_me不揭露 owner email不含 storage_key / faa_object_keySEC-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,
}
// limitclamp 到 1100。
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 filtertrue/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_accessowner 由 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 游標;格式錯誤回 errorhandler 轉 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 payloadapi §2
//
// 絕不含 storage_key / faa_object_key / owner email / file_checksumSEC-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防 enumerationSEC-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.nameowner 未設 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/visibilityowner-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-onlySEC-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 是一筆分享授權 DTOowner 檢視清單用)。
//
// 只揭露 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/sharesowner-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/sharesowner-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/:userIdowner-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
}

View File

@ -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
// 不含別人的 privateTDD §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 驗證未 finalizepending的 model 不進共享庫。
func TestLibrary_ExcludesNotReady(t *testing.T) {
r, repo := newSharingFixture(t, "me")
// pending modelUploadedAt=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-1profile 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 nameMinor-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 回相同 404enumeration 防護)。
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 emailSEC-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 modelUploadedAt=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 CRUDrestricted 授權管理)
// ==========================================================================
// 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 無 orgOIDC 現況)→ none安全預設stub
mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant}
assert.Equal(t, model.AccessNone,
canAccessModelForTest(ctx, "stranger", mTenant, noShare),
"tenant + 無 org → nonetenant stub")
}

View File

@ -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. applymodels 有 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 硬刪時連帶清 shareON 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 表應再次存在")
}

View File

@ -119,6 +119,16 @@ type Repository interface {
// 實作應更新 UpdatedAt若為新建則同時設定 CreatedAt。 // 實作應更新 UpdatedAt若為新建則同時設定 CreatedAt。
Save(ctx context.Context, d *Device) error 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 標記為軟刪除(設定 DeletedAt
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
} }
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
return nil 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 deviceis_representative=true、 // GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q無交易需求 // 未刪除);不存在回 ErrNotFound。in-memory 忽略 q無交易需求
// //

View File

@ -3,6 +3,7 @@ package device
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent") _, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
assert.ErrorIs(t, err, ErrNotFound) 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)
}

View File

@ -31,6 +31,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
return nil return nil
} }
// SetRegistered 設定 / 清除 registered_at註冊軸單欄 UPDATEfeature-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。
//
// - registerat != nilhandler 已先擋 already-registered這裡不重複判
// - unregisterat == nil清成 NULL對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1
// WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」冪等TDD §4.1)。
//
// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 tokenTDD §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 deviceis_representative=true、 // GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound在傳入 Querier / tx 上執行)。 // 未刪除);不存在回 ErrNotFound在傳入 Querier / tx 上執行)。
// //

View File

@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
_, err = r.List(ctx, owner) _, err = r.List(ctx, owner)
assert.Error(t, err, "已取消 ctx 的 List 應回 error") assert.Error(t, err, "已取消 ctx 的 List 應回 error")
} }
// ---------------------------------------------------------------------------
// SetRegistered註冊軸單欄 UPDATEfeature-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 → ErrNotFoundWHERE 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 → ErrNotFoundWHERE 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)
}

View File

@ -0,0 +1,152 @@
package model
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// readyModel 建一個 readyUploadedAt 已設)的 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")
// 無 orgOIDC 現況)→ 不可見(安全預設)
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 應被分頁走過一次")
}

View File

@ -8,6 +8,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings"
"sync" "sync"
"time" "time"
) )
@ -41,6 +43,54 @@ const (
SourcePreset Source = "preset" 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_idOIDC 現況不帶 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 的有效權限」。
// 由 canAccessModelsingle source of truth計算取最高。
type AccessLevel = string
const (
// AccessNone 無可見性(不該看到此 modelenumeration 防護一律回 404
AccessNone AccessLevel = "none"
// AccessViewer 可 list / get profile / downloadvisibility 命中或 share role=viewer
AccessViewer AccessLevel = "viewer"
// AccessEditor 可改 metadatashare role=editor含 viewer 全部權限。
AccessEditor AccessLevel = "editor"
// AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。
AccessOwner AccessLevel = "owner"
)
// ========================================================================== // ==========================================================================
// Model struct對齊 database.md §2.3 // Model struct對齊 database.md §2.3
// ========================================================================== // ==========================================================================
@ -79,12 +129,75 @@ type Model struct {
Source Source `json:"source"` Source Source `json:"source"`
SourceJobID string `json:"sourceJobId,omitempty"` SourceJobID string `json:"sourceJobId,omitempty"`
// Visibility 是廣播式公開對象private / tenant / public對齊 model_sharing 功能)。
// 既有 / 新建 model 預設 VisibilityPrivateDB DEFAULT 'private',零行為改變)。
Visibility Visibility `json:"visibility"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
UploadedAt *time.Time `json:"uploadedAt,omitempty"` UploadedAt *time.Time `json:"uploadedAt,omitempty"`
DeletedAt *time.Time `json:"deletedAt,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 + descriptionILIKE 包含)
// Ownednil = 全部可見true = 只我的false = 只別人分享/公開給我的。
Owned *bool
// 排序 + 分頁。
Sort string // 'created_at' | 'name' | 'file_size'handler 已 validate
Order string // 'asc' | 'desc'
Limit int // handler 已 clamp 到 1100
Cursor *Cursor // nil = 首頁
}
// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。
//
// 由 handler 以不透明 base64 編碼給前端(見 api §1.3repository 只吃解碼後的結構。
type Cursor struct {
// SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定:
// created_at → RFC3339 時間字串name → 字串file_size → 十進位整數字串。
SortValue string `json:"v"`
// ID 是上一頁最後一筆的 model idtie-breaker保證穩定分頁
ID string `json:"id"`
}
// ========================================================================== // ==========================================================================
// Filter / Repository // Filter / Repository
// ========================================================================== // ==========================================================================
@ -103,6 +216,11 @@ type Repository interface {
// Get 取得單一 Model不存在或已刪除回 ErrNotFound。 // Get 取得單一 Model不存在或已刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*Model, error) 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 列出 Modelfilter.OwnerUserID 不同於空字串時限定擁有者。 // List 依 filter 列出 Modelfilter.OwnerUserID 不同於空字串時限定擁有者。
List(ctx context.Context, filter ListFilter) ([]*Model, error) List(ctx context.Context, filter ListFilter) ([]*Model, error)
@ -111,6 +229,29 @@ type Repository interface {
// Delete 軟刪除。 // Delete 軟刪除。
Delete(ctx context.Context, id string) error 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 NULLready的 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 { type InMemoryRepository struct {
mu sync.RWMutex mu sync.RWMutex
models map[string]*Model 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。 // NewInMemoryRepository 建立一個空的記憶體 Repository。
func NewInMemoryRepository() *InMemoryRepository { func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{ return &InMemoryRepository{
models: make(map[string]*Model), 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。 // Get 取得單一 Model。
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) { func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
r.mu.RLock() r.mu.RLock()
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
return &cp, nil 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。 // List 依條件列出 Model。
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) { func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
r.mu.RLock() r.mu.RLock()
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
} else if cp.CreatedAt.IsZero() { } else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now cp.CreatedAt = now
} }
// visibility 預設 private對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
if cp.Visibility == "" {
cp.Visibility = VisibilityPrivate
}
cp.UpdatedAt = now cp.UpdatedAt = now
r.models[m.ID] = &cp r.models[m.ID] = &cp
return nil return nil
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
return nil return nil
} }
// ---------------------------------------------------------------------------
// InMemoryRepository — 模型共享方法
// ---------------------------------------------------------------------------
// Library 依查詢 user 身份列出可見 modelin-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
}
// filterowned 維度。
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 新增 / 更新一筆 shareby 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 就地排序 itemstie-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。 // 編譯時檢查:確保 InMemoryRepository 實作 Repository。
var _ Repository = (*InMemoryRepository)(nil) var _ Repository = (*InMemoryRepository)(nil)

View File

@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil)
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。 // modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size, const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
file_checksum, faa_object_key, target_chip, input_shape, classes, framework, 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。 // Get 取得單一 Model不存在或已軟刪除回 ErrNotFound。
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) { 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 // nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL
// 對齊 in-memory「zero value 即空字串」語意faa_object_key 等查詢端以 != '' 判斷)。 // 對齊 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 = ` const q = `
INSERT INTO models ( INSERT INTO models (
id, owner_user_id, name, description, storage_key, file_size, id, owner_user_id, name, description, storage_key, file_size,
file_checksum, faa_object_key, target_chip, input_shape, classes, framework, 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 ( ) VALUES (
$1, $2, $3, $4, $5, $6, $1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $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 ON CONFLICT (id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id, owner_user_id = EXCLUDED.owner_user_id,
@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
framework = EXCLUDED.framework, framework = EXCLUDED.framework,
source = EXCLUDED.source, source = EXCLUDED.source,
source_job_id = EXCLUDED.source_job_id, source_job_id = EXCLUDED.source_job_id,
visibility = EXCLUDED.visibility,
-- 保留原 created_at 僅當既有列未刪除已刪除復活或值不同則用新值 -- 保留原 created_at 僅當既有列未刪除已刪除復活或值不同則用新值
created_at = CASE created_at = CASE
WHEN models.deleted_at IS NULL THEN models.created_at 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 m.Framework, // $12
string(m.Source), // $13 string(m.Source), // $13
nullableUUID(m.SourceJobID), // $14 nullableUUID(m.SourceJobID), // $14
createdAt, // $15 visibility, // $15
m.UploadedAt, // $16 createdAt, // $16
m.DeletedAt, // $17 m.UploadedAt, // $17
m.DeletedAt, // $18
) )
if err != nil { if err != nil {
return fmt.Errorf("model: pg Save upsert: %w", err) return fmt.Errorf("model: pg Save upsert: %w", err)
@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) {
&framework, &framework,
&m.Source, &m.Source,
&sourceJobID, &sourceJobID,
&m.Visibility,
&m.CreatedAt, &m.CreatedAt,
&m.UpdatedAt, &m.UpdatedAt,
&m.UploadedAt, &m.UploadedAt,

View File

@ -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 §1librarycursor 分頁 / sort / filter / q
// - adr-017-model-library-access.md 決策 3model_shares schema
// - migrations/0006_model_sharing.up.sqlvisibility 欄 + model_shares 表 + index
//
// 可見性 predicatesingle 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-4org_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 對齊。內部 keystorage_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 身份列出可見 modelcursor 分頁)。見檔頭 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)
// 可見性 predicateTDD §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,
}
// filterowned 維度。
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 + descriptionTDD §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 cursorWHERE (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_accessowner > share.role > public/tenant(viewer)。owner 由呼叫端已知owner_user_id=userID
// 但此處 Library 已用 predicate 過濾出可見列,故 access 一定 != none。
// owner 的判斷在 handleris_me這裡計算「非 owner 情境」的 accessowner 情境 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)
}

View File

@ -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")
// 空 orgOIDC 現況)→ 不可見(安全預設,即使 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 驗證 filterowned / 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 nameMinor-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 }

View File

@ -185,5 +185,8 @@ func clonePreset(m *Model) *Model {
t := *m.UploadedAt t := *m.UploadedAt
cp.UploadedAt = &t cp.UploadedAt = &t
} }
// preset 是公用模型語意等同全平台可見visibility=public
// 在此統一標記preset 宣告區不必逐筆設 Visibility。
cp.Visibility = VisibilityPublic
return &cp return &cp
} }

View File

@ -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 COLUMNDROP COLUMN 也會連帶移除 constraint
-- 此處顯式先移以求對稱清楚)。
ALTER TABLE models DROP CONSTRAINT IF EXISTS chk_models_visibility;
ALTER TABLE models DROP COLUMN IF EXISTS visibility;

View File

@ -0,0 +1,49 @@
-- 0006_model_sharing.up.sql
--
-- 模型共享Model SharingL 級新功能。在既有 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。
--
-- 環境事實(與 00010005 相同已驗證PostgreSQL 14.23gen_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 CASCADEmodel 硬刪時連帶清 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_idusers 主鍵 join 成本低,
-- owner 維度沿用既有 idx_models_owner_active不另建。