Compare commits

..

3 Commits

Author SHA1 Message Date
6a797d5eb5 feat(frontend): 設備管理三態/註冊 + 模型共享庫/公開設定(B + C)
B 設備管理:device-store 補 registeredAt + register/unregister actions;
deriveTriState 三態(已連接/未連接/已連接未註冊);三態 badge(warning token
+ icon + 文字不只色);排序/filter chips;註冊 UI(明確區分取消註冊≠移除)。

C 模型共享:/models/library cursor 無限捲動 + 搜尋/filter/排序;visibility badge
三態;公開設定 Dialog(RadioGroup + shares 管理 + public 警告 + 二次確認);
profile 頁 owner/公開雙態;radio-group 新元件;owner 用 name 不洩 email。

共用檔 types/api.ts(B error codes)+ i18n en/zh(devices.* B / models.* C)。
零新 Design Token。reviewer B(0C/0M) + C(0C/0M、設計12/12 API8/8) 通過。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:30:22 +08:00
47a1d4d0ef 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>
2026-08-02 16:29:50 +08:00
17134e8eae feat(local-agent): camera 三平台 input device(ADR-020 WP-1/2)
camera 即時推論開不了根因:vendor decoder-only ffmpeg --disable-everything
沒 enable 任何 input device → macOS avfoundation 認不得。

- WP-1 macOS:ffmpeg rebuild 加 --enable-avfoundation + --enable-indev=avfoundation
  (--disable-autodetect 會靜默 disable、兩行要一起帶)。-list_devices 列出相機、
  +22KB、LGPL-safe。sha 已核對。
- WP-2:buildCaptureArgs 補 Linux v4l2 分支(原誤落 avfoundation default 必壞)+
  ListFFmpegDevices Linux glob /dev/video*。四路明確 case + 回歸鎖。

reviewer WP-1(0C/0M/0m) + WP-2(0C/0M/2m) 通過。Windows dshow / Linux v4l2 實機驗待機器。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:28:51 +08:00
64 changed files with 8897 additions and 118 deletions

View File

@ -178,6 +178,8 @@ vendor-ffmpeg-macos-build: ## macOS從源碼 build LGPL v3 decoder-only ffmpe
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-avfoundation \
--enable-indev=avfoundation \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \

View File

@ -17,7 +17,7 @@ import (
const stderrTailBytes = 8 * 1024
// FFmpegCamera captures webcam frames using ffmpeg subprocess.
// Supports macOS (AVFoundation) and Windows (DirectShow).
// Supports macOS (AVFoundation), Windows (DirectShow) and Linux (Video4Linux2).
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
type FFmpegCamera struct {
@ -167,10 +167,33 @@ func (c *FFmpegCamera) StderrTail() string {
// buildCaptureArgs returns the ffmpeg arguments for the current OS.
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
return buildCaptureArgsForOS(runtime.GOOS, cameraIndex, cameraName, width, height, framerate)
}
// buildCaptureArgsForOS 依指定 goos 組 ffmpeg capture args把平台判斷抽成參數以便
// table test 三平台輸出(不用 mock runtime.GOOS。三平台各用不同 input deviceindev
// - macOS → avfoundation-i "<index>:none"
// - Windows → dshow-i video="<name>"
// - Linux → v4l2-i /dev/video<N>
//
// 三平台後段皆接 -f image2pipe -vcodec mjpeg -q:v 5 -an -MJPEG pipe 架構共用。
// 對照見 ADR-020 §2.2 三平台 capture args 對照表。
func buildCaptureArgsForOS(goos string, cameraIndex int, cameraName string, width, height, framerate int) []string {
videoSize := fmt.Sprintf("%dx%d", width, height)
fps := fmt.Sprintf("%d", framerate)
switch runtime.GOOS {
// captureTail 是三平台共用的輸出段(把 raw frame 轉成 stdout 上的 MJPEG stream
captureTail := func(args []string) []string {
return append(args,
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
)
}
switch goos {
case "windows":
// DirectShow on Windows: -f dshow -i video="Camera Name"
inputName := cameraName
@ -183,30 +206,41 @@ func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framera
inputName = "Integrated Camera"
}
}
return []string{
return captureTail([]string{
"-f", "dshow",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("video=%s", inputName),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
default:
})
case "linux":
// Video4Linux2 on Linux: -f v4l2 -i /dev/video<N>
// v4l2 以裝置節點路徑(非 index指定攝影機cameraIndex 對應 /dev/video<N>
// 這是 Linux 攝影機的慣例(/dev/video0 = 第一支。cameraName 在 Linux 未使用。
return captureTail([]string{
"-f", "v4l2",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("/dev/video%d", cameraIndex),
})
case "darwin":
// AVFoundation on macOS: -f avfoundation -i "index:none"
return []string{
return captureTail([]string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
})
default:
// 未知平台:明確落到 macOS 的 avfoundation 是錯的(原本 default 就是這個 bug
// 保留 avfoundation 作為最後手段,但這只是為了讓非三大平台不至於 build 失敗;
// 實務上未知平台的 camera 抓取本就不支援,會在 ffmpeg 層以「Unknown input format」失敗。
// 三大目標平台darwin/windows/linux都有明確 case、不會落到這裡。
return captureTail([]string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("%d:none", cameraIndex),
})
}
}

View File

@ -0,0 +1,192 @@
package camera
import (
"strings"
"testing"
)
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input deviceindev
// macOS→avfoundation、Windows→dshow、Linux→v4l2。這是 ADR-020 的核心修復:
// 原本 Linux 會誤落 default 分支用 avfoundation → 必壞。
func TestBuildCaptureArgsForOS(t *testing.T) {
const (
width = 640
height = 480
fps = 30
index = 0
)
tests := []struct {
name string
goos string
cameraName string
// wantInputFlag 是預期的 "-f <indev>" 值
wantIndev string
// wantInputArg 是 "-i" 後面的值
wantInputArg string
}{
{
name: "macOS uses avfoundation with index:none",
goos: "darwin",
wantIndev: "avfoundation",
wantInputArg: "0:none",
},
{
name: "Windows uses dshow with video=name",
goos: "windows",
cameraName: "Integrated Camera",
wantIndev: "dshow",
wantInputArg: "video=Integrated Camera",
},
{
name: "Linux uses v4l2 with /dev/video path",
goos: "linux",
wantIndev: "v4l2",
wantInputArg: "/dev/video0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := buildCaptureArgsForOS(tt.goos, index, tt.cameraName, width, height, fps)
gotIndev := valueAfterFlag(args, "-f") // 第一個 -f 是 input format
if gotIndev != tt.wantIndev {
t.Errorf("indev = %q, want %q\nargs: %v", gotIndev, tt.wantIndev, args)
}
gotInput := valueAfterFlag(args, "-i")
if gotInput != tt.wantInputArg {
t.Errorf("input = %q, want %q\nargs: %v", gotInput, tt.wantInputArg, args)
}
// 三平台後段皆須為 image2pipe / mjpegMJPEG pipe 架構共用。
if !containsSeq(args, "-f", "image2pipe") {
t.Errorf("missing image2pipe output, args: %v", args)
}
if !containsSeq(args, "-vcodec", "mjpeg") {
t.Errorf("missing mjpeg vcodec, args: %v", args)
}
if args[len(args)-1] != "-" {
t.Errorf("last arg should be stdout '-', got %q", args[len(args)-1])
}
})
}
}
// TestBuildCaptureArgsForOS_LinuxIndexToDevicePath 驗證 Linux 的 cameraIndex 正確
// 對應到 /dev/video<N> 節點路徑。
func TestBuildCaptureArgsForOS_LinuxIndexToDevicePath(t *testing.T) {
for _, idx := range []int{0, 1, 2, 10} {
args := buildCaptureArgsForOS("linux", idx, "", 640, 480, 30)
want := "/dev/video" + itoa(idx)
if got := valueAfterFlag(args, "-i"); got != want {
t.Errorf("index %d → input %q, want %q", idx, got, want)
}
}
}
// TestBuildCaptureArgsForOS_LinuxNotAVFoundation 是 ADR-020 的回歸鎖Linux 絕不能
// 用 avfoundationmacOS 專用)。若未來有人把 Linux case 拿掉、讓它落回 default
// 這個測試會抓到。
func TestBuildCaptureArgsForOS_LinuxNotAVFoundation(t *testing.T) {
args := buildCaptureArgsForOS("linux", 0, "", 640, 480, 30)
joined := strings.Join(args, " ")
if strings.Contains(joined, "avfoundation") {
t.Fatalf("Linux must NOT use avfoundation (macOS-only), args: %v", args)
}
if !strings.Contains(joined, "v4l2") {
t.Fatalf("Linux must use v4l2, args: %v", args)
}
}
// TestParseV4L2Devices 驗證 /dev/video* 路徑清單解析成 CameraInfo且依 index 數值
// 排序video10 排在 video2 之後、而非字典序)。
func TestParseV4L2Devices(t *testing.T) {
paths := []string{
"/dev/video10",
"/dev/video2",
"/dev/video0",
"/dev/video-not-a-number", // 應被略過
"/dev/videoX", // 應被略過
}
got := parseV4L2Devices(paths)
wantIndexes := []int{0, 2, 10}
if len(got) != len(wantIndexes) {
t.Fatalf("got %d devices, want %d: %+v", len(got), len(wantIndexes), got)
}
for i, want := range wantIndexes {
if got[i].Index != want {
t.Errorf("device[%d].Index = %d, want %d", i, got[i].Index, want)
}
if got[i].Name != "/dev/video"+itoa(want) {
t.Errorf("device[%d].Name = %q, want /dev/video%d", i, got[i].Name, want)
}
}
}
// TestParseV4L2Devices_Empty 驗證無裝置時回 nil/空。
func TestParseV4L2Devices_Empty(t *testing.T) {
if got := parseV4L2Devices(nil); len(got) != 0 {
t.Errorf("expected no devices, got %+v", got)
}
}
// TestV4L2Index 驗證從路徑取 index。
func TestV4L2Index(t *testing.T) {
cases := map[string]int{
"/dev/video0": 0,
"/dev/video12": 12,
"/dev/video": -1,
"/dev/videoab": -1,
"videoX": -1,
}
for path, want := range cases {
if got := v4l2Index(path); got != want {
t.Errorf("v4l2Index(%q) = %d, want %d", path, got, want)
}
}
}
// --- helpers ---
// valueAfterFlag 回傳 args 中第一個等於 flag 的元素的下一個值。
func valueAfterFlag(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
// containsSeq 判斷 args 是否包含連續的 a b 兩個元素。
func containsSeq(args []string, a, b string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == a && args[i+1] == b {
return true
}
}
return false
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf []byte
for n > 0 {
buf = append([]byte{byte('0' + n%10)}, buf...)
n /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}

View File

@ -3,8 +3,10 @@ package camera
import (
"fmt"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
)
@ -19,7 +21,14 @@ func DetectFFmpeg() bool {
// Automatically selects the correct capture framework for the current OS:
// - macOS: AVFoundation
// - Windows: DirectShow (dshow)
// - Linux: Video4Linux2 (v4l2, enumerated from /dev/video*)
func ListFFmpegDevices() []CameraInfo {
// Linux 走 /dev/video* 列舉、不依賴 ffmpeg binary-list_devices 在部分 v4l2 build
// 不穩定),故不要求 DetectFFmpeg 也能列出裝置節點;實際抓 frame 時才需要 ffmpeg。
if runtime.GOOS == "linux" {
return listV4L2Devices()
}
if !DetectFFmpeg() {
return nil
}
@ -132,3 +141,66 @@ func parseDShowOutput(output string) []CameraInfo {
return cameras
}
// --- Linux (Video4Linux2) ---
// v4l2DeviceGlob 是列舉 v4l2 攝影機節點的 glob pattern。抽成變數讓測試可覆寫成
// 假的目錄,不依賴實機 /dev。
var v4l2DeviceGlob = "/dev/video*"
// v4l2DeviceIndexRe 從 /dev/video<N> 取出 index N。
var v4l2DeviceIndexRe = regexp.MustCompile(`video(\d+)$`)
// listV4L2Devices 列舉 Linux 上的 v4l2 攝影機節點(/dev/video*)。
//
// 為何用 glob 而非 ffmpeg -list_devicesv4l2 indev 的 -list_devices 支援度依 ffmpeg
// 版本而異、部分 build 不輸出可解析清單;直接列舉 /dev/video* 節點是最穩定的跨版本做法。
//
// 注意:/dev/video* 也包含非攝影機的 V4L2 節點(如 metadata / output device這裡先
// 全數列出、由使用者選擇;抓 frame 失敗會在 ffmpeg 層以明確錯誤浮現camera 修 bug 後不再吞錯)。
func listV4L2Devices() []CameraInfo {
matches, err := filepath.Glob(v4l2DeviceGlob)
if err != nil {
return nil
}
return parseV4L2Devices(matches)
}
// parseV4L2Devices 把 /dev/video* 路徑清單轉成 CameraInfo依 index 排序)。
// 抽出來讓測試可直接餵路徑清單、不碰檔案系統。
func parseV4L2Devices(paths []string) []CameraInfo {
// 依裝置 index 數值排序Glob 回傳為字典序video10 會排在 video2 前,需正規化)。
sorted := append([]string(nil), paths...)
sort.Slice(sorted, func(i, j int) bool {
return v4l2Index(sorted[i]) < v4l2Index(sorted[j])
})
var cameras []CameraInfo
for _, path := range sorted {
idx := v4l2Index(path)
if idx < 0 {
continue // 不是 /dev/video<N> 形式、略過
}
cameras = append(cameras, CameraInfo{
ID: fmt.Sprintf("cam-%d", idx),
Name: path, // Linux 以裝置節點路徑作為名稱buildCaptureArgs 用 index 組回路徑)
Index: idx,
Width: 640,
Height: 480,
})
}
return cameras
}
// v4l2Index 從 /dev/video<N> 取出 N非該形式回 -1。
func v4l2Index(path string) int {
m := v4l2DeviceIndexRe.FindStringSubmatch(path)
if len(m) != 2 {
return -1
}
n, err := strconv.Atoi(m[1])
if err != nil {
return -1
}
return n
}

View File

@ -18,17 +18,24 @@ LGPL static build 來源,採「自 build decoder-only」策略binary 直接
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
| Assembler | nasm 3.01Homebrew bottlecompiled 2025-10-11 |
| Homebrew | 5.1.6 |
| Build date | 2026-04-15 |
| Build date | 2026-08-02ADR-020 rebuild加回 avfoundation indev |
| Build flags | 見下方 Configure flags 區塊(與 `Makefile``vendor-ffmpeg-macos-build` target 一致) |
> **ADR-020 變更2026-08-02**configure 加 `--enable-avfoundation` + `--enable-indev=avfoundation`
> 讓 camera 即時推論可用 avfoundation 抓實體攝影機。詳見
> `docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md`
> 之前的 decoder-only build2026-04-15沒編任何 indev導致 `Unknown input format: 'avfoundation'`、camera 開不了。
## Binary sha256
| 檔案 | sha256 |
|------|--------|
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
| `ffmpeg` | `1afa56dabb4ba4fb45323e37510602309ffd544d9e7ecb2d8267697a7cc16626` |
| `ffprobe` | `501ec3fbe450c44f4c28cd7534940c6a16a42f8584e852b4f315de565aba414e` |
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
> 舊值2026-04-15 decoder-only、無 indevffmpeg `c3cb9f1d…992c` / ffprobe `bd388fb4…0c5e`
計算指令:
```bash
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
@ -38,17 +45,21 @@ shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
| 檔案 | Bytes | 人類可讀 |
|------|-------|---------|
| `ffmpeg` | 6,007,520 | 5.7 MB |
| `ffprobe` | 5,865,568 | 5.6 MB |
| `ffmpeg` | 6,030,224 | 5.8 MB |
| `ffprobe` | 5,892,384 | 5.6 MB |
實測比 TDD 原估 1015 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter無 GPL 元件。
> **avfoundation indev 體積增量ADR-020**ffmpeg 6,007,520 → 6,030,224 bytes
> **+22,704 bytes+0.02 MB**,遠低於 ADR-020 估的 < 0.5 MB avfoundation indev 是薄封裝
> 呼叫系統 AVFoundation / CoreMedia / CoreVideo framework不自帶任何 codec。
### Build 實測耗時
- **2 分 44 秒**`make vendor-ffmpeg-macos-build``time` 量測)
- user: 559.60ssystem: 56.03swall-clock: 164.56s
- CPU 使用率:~374%macOS x86_648 核 Intel
- 比 TDD 原估 1020 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
- **3 分 57 秒**2026-08-02 ADR-020 rebuild`make vendor-ffmpeg-macos-build``time` 量測)
- user: 870.32ssystem: 92.27swall-clock: 236.62s
- CPU 使用率:~406%macOS x86_648 核 Intel
- 2026-04-15 首次 decoder-only build 為 2 分 44 秒;本次略增因多編 avfoundation indev
## License
@ -64,6 +75,13 @@ build 不 link 以下 GPL-only 元件:
僅使用 libavcodec 內建的 LGPL native decoderh264 / hevc / mpeg1video / mpeg2video /
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
**avfoundation indevADR-020為 LGPL-safe不引入任何 GPL 元件**avfoundation input device
只是薄封裝、透過 macOS 系統的 AVFoundation / CoreMedia / CoreVideo framework 抓實體攝影機 frame
不含任何第三方 / GPL codec。加 `--enable-avfoundation` + `--enable-indev=avfoundation` 後,
`ffmpeg -version` 的 configuration line 仍**不含** `--enable-gpl` / `libx264` / `libx265`(已實測驗證),
`--enable-version3`LGPL v3合規未破。新增 link 的皆為 Apple 系統 frameworkAVFoundation /
Foundation / CoreGraphics / libobjc非第三方 dylib。
---
## Configure flags完整複製
@ -82,6 +100,8 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-avfoundation \
--enable-indev=avfoundation \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
@ -109,6 +129,8 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
| `--disable-everything` | 先關全部,白名單 enable確保不額外 link 任何 GPL 元件 |
| `--enable-small` | 最佳化體積而非速度 |
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipeffmpeg 內部 stdin/stdout |
| `--enable-avfoundation` | **ADR-020** camera 抓實體攝影機需 AVFoundation framework。因本 build 用 `--disable-autodetect`(連 AVFoundation 框架都不自動偵測),必須顯式 `--enable-avfoundation` 才能讓下面的 `avfoundation` indev 的依賴(`avfoundation corevideo coremedia pthreads`)被滿足。**少了這行、`--enable-indev=avfoundation` 會被 configure 靜默 disable`WARNING: Disabled avfoundation_indev because not all dependencies are satisfied`** |
| `--enable-indev=avfoundation` | **ADR-020** camera 即時推論的 macOS input device。ffmpeg `-f avfoundation -i "<index>:none"` 從實體攝影機抓 raw frame → MJPEG pipe。少了它會 `Unknown input format: 'avfoundation'`。LGPL-safe 薄封裝、體積增量 < 0.03MB |
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
| `--enable-decoder=h264,hevc,...` | 涵蓋常見 codecH.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
@ -192,6 +214,13 @@ codesign -v vendor/ffmpeg/macos/ffprobe
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
file /tmp/test.jpg
# 預期JPEG image data
# 7.ADR-020確認 avfoundation indev 有編進去、可列出攝影機
vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
# 預期D avfoundation
vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i "" 2>&1
# 預期:列出 AVFoundation video/audio devices不再 Unknown input format: 'avfoundation'
# 註:-list_devices true 列完裝置後會以非 0 退出Error opening input屬正常非失敗。
```
---
@ -254,22 +283,28 @@ $ vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>&1 \
- `mpegts` — MPEG Transport Stream
- `matroska,webm` — ok
### 4. Dynamic dependencies (`otool -L`)
### 4. Dynamic dependencies (`otool -L`)ADR-020 rebuild 後)
```
vendor/ffmpeg/macos/ffmpeg:
/System/Library/Frameworks/Foundation.framework/.../Foundation ← ADR-020 新增avfoundation 依賴)
/usr/lib/libSystem.B.dylib
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
/System/Library/Frameworks/AVFoundation.framework/.../AVFoundation ← ADR-020 新增
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
/System/Library/Frameworks/CoreMedia.framework/.../CoreMedia
/System/Library/Frameworks/CoreGraphics.framework/.../CoreGraphics ← ADR-020 新增avfoundation suggest
/System/Library/Frameworks/CoreFoundation.framework/.../CoreFoundation
/usr/lib/libobjc.A.dylib ← ADR-020 新增Objective-C runtime
vendor/ffmpeg/macos/ffprobe:
(同上四個 macOS system framework
(同上一組 macOS system framework
```
- ✅ 只依賴 macOS 系統內建 framework`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`
- ✅ 只依賴 macOS 系統內建 framework / dylibAVFoundation / Foundation / CoreVideo / CoreMedia /
CoreGraphics / CoreFoundation / libSystem / libobjc
- ✅ **無任何第三方 dylib**`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
- ✅ 等同於 self-contained binary搬到任一台 macOS 10.15+ x86_64 都能跑
- ✅ 新增的皆為 Apple 第一方系統 framework**LGPL 合規未破**,仍是 self-contained binary搬到任一台
macOS 10.15+ x86_64 都能跑
### 5. Code signing
@ -278,7 +313,26 @@ $ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
```
ad-hoc simbol signing okGatekeeper 可過。
ad-hoc symbol signing okGatekeeper 可過。
### 6. avfoundation indevADR-020本次 rebuild 新增)
```
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
D avfoundation
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i ""
[AVFoundation indev @ ...] AVFoundation video devices:
[AVFoundation indev @ ...] [0] FaceTime HD相機內建
[AVFoundation indev @ ...] [1] Capture screen 0
[AVFoundation indev @ ...] AVFoundation audio devices:
[AVFoundation indev @ ...] [0] MacBook Pro的麥克風
```
- ✅ `-devices` 列出 `avfoundation`demuxing supported
- ✅ `-list_devices true` 成功列出實體攝影機FaceTime HD 相機)+ 音訊裝置
- ✅ 不再出現 `Unknown input format: 'avfoundation'`camera 開不了的根因已解)
- 註:`-list_devices true` 列完裝置後以非 0 退出(`Error opening input`)屬正常行為,非失敗。
---

Binary file not shown.

Binary file not shown.

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
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 打
// local-agent issue-token。契約 path 為 /api/devices/:serial/local-upload-ticket
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial

View File

@ -21,6 +21,14 @@ const (
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
// ErrCodeConflict 對齊 HTTP 409unique 約束衝突 — 同 owner+serial 重複註冊)。
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。
// DB 接入塊 5.4 fail-fast 策略PG 連線失敗 / context 逾時 → 503讓 load balancer 知道
// 這台不健康,而非回假資料或 500500 會誤導為「程式 bug」503 才是「依賴暫時不可用」)。

View File

@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
// Phase 0.9 模型庫 model 直連 FAA 下載ADR-017 (a))。
g.GET("/models/:id/download", modelsDownloadHandler(deps))
// 模型共享library / profile / visibility / shares
registerModelSharingRoutes(g, deps)
// load-to-device 雛形先 stub完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
@ -67,6 +70,9 @@ type ModelResponse struct {
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
// Visibility模型共享功能新增。既有前端未讀此欄不受影響加欄相容
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
Visibility string `json:"visibility,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
Visibility: m.Visibility,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
UploadedAt: m.UploadedAt,
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
WriteDBError(c, deps.Logger, "get model", err)
return
}
// 第一階段 owner-onlyB 分享後續階段);非 owner 回 403。
if m.OwnerUserID != userID {
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
// 模型共享放寬owner-only → 共享權限檢查TDD §5 download 連帶變更)。
// 與 profile 可見性共用同一 canAccessModelsingle source of truth杜絕邏輯漂移SEC-2
// 不命中回 404不是 403防 enumeration與 profile 一致SEC-1
if canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) == model.AccessNone {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
return
}

View File

@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變TDD §5 download 放寬):
// 非 owner 且無任何可見性private model下載回 404不是 403——防 enumerationSEC-1
// 與 profile 的 canAccessModel 判斷一致single source of truth
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
iss := &fakeIssuer{token: "fdt_x"}
// 登入 user = demo-user但 model owner = other-user
// 登入 user = demo-user但 model owner = other-user,且 model 為 private預設
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
@ -228,9 +231,63 @@ func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404防 enumeration")
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
assert.Equal(t, 0, iss.calls, "should not issue token when no access")
}
// TestModelsDownload_PublicModelNonOwner 驗證 public model 非 owner 也能下載(共享放寬)。
func TestModelsDownload_PublicModelNonOwner(t *testing.T) {
iss := &fakeIssuer{token: "fdt_pub"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-pub", "other-user", "models/other-user/pub.nef")
// owner 把 model 設為 public。
m, err := repo.Get(context.Background(), "m-pub")
require.NoError(t, err)
m.Visibility = model.VisibilityPublic
require.NoError(t, repo.Save(context.Background(), m))
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models/m-pub/download", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "public model 非 owner 應可下載body=%s", w.Body.String())
assert.Equal(t, 1, iss.calls, "public model 應簽 download token")
}
// TestModelsDownload_SharedModelNonOwner 驗證被 restricted 分享的 grantee 也能下載。
func TestModelsDownload_SharedModelNonOwner(t *testing.T) {
iss := &fakeIssuer{token: "fdt_share"}
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
seedConvertedModel(t, repo, "m-shared", "other-user", "models/other-user/shared.nef")
// owner 把 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。
Save(ctx context.Context, d *Device) error
// SetRegistered 設定 / 清除註冊時間註冊軸單欄翻轉feature-device-mgmt-tdd §3.3)。
//
// - at != nil → 註冊registered_at = *at
// - at == nil → 取消註冊registered_at = NULL保留列絕不軟刪 / 撤 token
//
// 只作用於「未刪除、非 representative」的 device縱深第三層配合 handler 的 owner /
// representative / already-registered 檢查);不符則回 ErrNotFound。register 端的
// already-registered 判斷由 handler 先擋(回 409本方法不重複判。
SetRegistered(ctx context.Context, id string, at *time.Time) error
// Delete 標記為軟刪除(設定 DeletedAt
Delete(ctx context.Context, id string) error
}
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
return nil
}
// SetRegistered 設定 / 清除某 device 的 registered_at單欄翻轉
//
// 語意對齊 PostgresRepository.SetRegistered只作用於未刪除、非 representative 的 device
// 不符(不存在 / 已軟刪 / representative回 ErrNotFound縱深第三層。一律更新 UpdatedAt。
// at==nil 清成未註冊保留列at!=nil 設為註冊時間。
func (r *InMemoryRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
d, ok := r.devices[id]
if !ok || d.DeletedAt != nil || d.IsRepresentative {
return ErrNotFound
}
now := time.Now().UTC()
if at != nil {
t := at.UTC()
d.RegisteredAt = &t
} else {
d.RegisteredAt = nil
}
d.UpdatedAt = now
return nil
}
// GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q無交易需求
//

View File

@ -3,6 +3,7 @@ package device
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
assert.ErrorIs(t, err, ErrNotFound)
}
// ---------------------------------------------------------------------------
// SetRegistered註冊軸單欄翻轉feature-device-mgmt-tdd §3.3
// ---------------------------------------------------------------------------
// SetRegistered set → registered_at 有值set nil → 清空。
func TestInMemoryRepository_SetRegistered_SetAndClear(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
// 初始未註冊。
got, err := r.Get(ctx, "d1")
require.NoError(t, err)
require.Nil(t, got.RegisteredAt)
// set → 已註冊。
now := time.Now().UTC()
require.NoError(t, r.SetRegistered(ctx, "d1", &now))
got, err = r.Get(ctx, "d1")
require.NoError(t, err)
require.NotNil(t, got.RegisteredAt)
assert.True(t, now.Equal(*got.RegisteredAt))
// set nil → 退回未註冊,列仍在。
require.NoError(t, r.SetRegistered(ctx, "d1", nil))
got, err = r.Get(ctx, "d1")
require.NoError(t, err, "unregister 不刪列")
assert.Nil(t, got.RegisteredAt)
}
// SetRegistered 冪等:對已 NULL 的列再 set nil → 成功no-op
func TestInMemoryRepository_SetRegistered_ClearIdempotent(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
require.NoError(t, r.SetRegistered(ctx, "d1", nil), "未註冊清 nil 應冪等成功")
got, _ := r.Get(ctx, "d1")
assert.Nil(t, got.RegisteredAt)
}
// SetRegistered 對 representative device → ErrNotFound縱深第三層
func TestInMemoryRepository_SetRegistered_RejectsRepresentative(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: "u", AgentID: "ag", IsRepresentative: true}))
now := time.Now().UTC()
assert.ErrorIs(t, r.SetRegistered(ctx, "rep", &now), ErrNotFound,
"representative 不可註冊")
}
// SetRegistered 對已軟刪 device → ErrNotFound。
func TestInMemoryRepository_SetRegistered_RejectsDeleted(t *testing.T) {
ctx := context.Background()
r := NewInMemoryRepository()
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
require.NoError(t, r.Delete(ctx, "d1"))
now := time.Now().UTC()
assert.ErrorIs(t, r.SetRegistered(ctx, "d1", &now), ErrNotFound)
}
// SetRegistered 對不存在 device → ErrNotFound。
func TestInMemoryRepository_SetRegistered_NotFound(t *testing.T) {
r := NewInMemoryRepository()
now := time.Now().UTC()
assert.ErrorIs(t, r.SetRegistered(context.Background(), "ghost", &now), ErrNotFound)
}

View File

@ -31,6 +31,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
return nil
}
// SetRegistered 設定 / 清除 registered_at註冊軸單欄 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、
// 未刪除);不存在回 ErrNotFound在傳入 Querier / tx 上執行)。
//

View File

@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
_, err = r.List(ctx, owner)
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"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
@ -41,6 +43,54 @@ const (
SourcePreset Source = "preset"
)
// ==========================================================================
// Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1
// ==========================================================================
// Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。
// 與 model_shares點對點分享正交。
type Visibility = string
const (
// VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。
VisibilityPrivate Visibility = "private"
// VisibilityTenant 同租戶(同 org_id可見。
// 依賴 users.org_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
// ==========================================================================
@ -79,12 +129,75 @@ type Model struct {
Source Source `json:"source"`
SourceJobID string `json:"sourceJobId,omitempty"`
// Visibility 是廣播式公開對象private / tenant / public對齊 model_sharing 功能)。
// 既有 / 新建 model 預設 VisibilityPrivateDB DEFAULT 'private',零行為改變)。
Visibility Visibility `json:"visibility"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// ==========================================================================
// ModelShare點對點分享關聯對齊 ADR-017 決策 3 B1 / model_shares 表)
// ==========================================================================
// ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。
type ModelShare struct {
ModelID string `json:"modelId"`
GranteeUserID string `json:"granteeUserId"`
Role string `json:"role"` // 'viewer' | 'editor'
GrantedBy string `json:"grantedBy"`
CreatedAt time.Time `json:"createdAt"`
}
// LibraryItem 是共享庫列表的一列Model + 該列相對於查詢 user 的存取資訊。
//
// owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1
// SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。
type LibraryItem struct {
Model *Model
OwnerName string
OwnerOrgID string
SharedWithMe bool
MyAccess AccessLevel
}
// LibraryQuery 是共享庫列表查詢的參數(對齊 api §1
//
// Viewer 身份UserID / UserOrgID決定可見範圍其餘為 filter / 排序 / cursor 分頁。
type LibraryQuery struct {
// Viewer 身份(可見性 predicate 的 input
UserID string
UserOrgID string // 空字串 → tenant 維度不命中任何 model安全預設
// filter皆可選空值 = 不過濾該維度)。
TargetChip string
Source Source
Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略
Q string // 搜尋 name + 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
// ==========================================================================
@ -103,6 +216,11 @@ type Repository interface {
// Get 取得單一 Model不存在或已刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*Model, error)
// GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users避免 N+1
// 供 profile handler 顯示擁有者名api §2 owner.name。ownerName 可能為空owner 未設 name
// 不存在或已刪除回 ErrNotFound。
GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err error)
// List 依 filter 列出 Modelfilter.OwnerUserID 不同於空字串時限定擁有者。
List(ctx context.Context, filter ListFilter) ([]*Model, error)
@ -111,6 +229,29 @@ type Repository interface {
// Delete 軟刪除。
Delete(ctx context.Context, id string) error
// ── 模型共享model_sharing 功能新增)─────────────────────────────────
// Library 依查詢 user 身份列出「可見」的 model我的 public tenant同org 分享給我),
// 支援 filter / 排序 / cursor 分頁。回傳 items已含 owner_name / my_access / shared_with_me
// 與是否還有下一頁hasMore。preset 由 handler 層 union不在此。
//
// 只列 uploaded_at IS NOT 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 {
mu sync.RWMutex
models map[string]*Model
// shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。
shares map[string]map[string]*ModelShare
// orgs 記錄 userID → org_id供 in-memory Library 判 tenant 可見性(測試注入用)。
// production 走 Postgres 實作in-memory 主要供 unit test故用簡易注入而非 join users。
orgs map[string]string
// names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。
names map[string]string
}
// NewInMemoryRepository 建立一個空的記憶體 Repository。
func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{
models: make(map[string]*Model),
shares: make(map[string]map[string]*ModelShare),
orgs: make(map[string]string),
names: make(map[string]string),
}
}
// SetUserOrg 設定某 user 的 org_id僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。
// production 的 Postgres 實作直接 join users.org_id不需此方法。
func (r *InMemoryRepository) SetUserOrg(userID, orgID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.orgs[userID] = orgID
}
// SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name
// production 的 Postgres 實作直接 join users.name不需此方法。
func (r *InMemoryRepository) SetUserName(userID, name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.names[userID] = name
}
// Get 取得單一 Model。
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
r.mu.RLock()
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
return &cp, nil
}
// GetWithOwner 取單一 Model + owner 顯示名稱in-memory 從 names map 取,測試以 SetUserName 注入)。
func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.models[id]
if !ok || m.DeletedAt != nil {
return nil, "", ErrNotFound
}
cp := *m
return &cp, r.names[m.OwnerUserID], nil
}
// List 依條件列出 Model。
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
r.mu.RLock()
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
// visibility 預設 private對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
if cp.Visibility == "" {
cp.Visibility = VisibilityPrivate
}
cp.UpdatedAt = now
r.models[m.ID] = &cp
return nil
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
return nil
}
// ---------------------------------------------------------------------------
// InMemoryRepository — 模型共享方法
// ---------------------------------------------------------------------------
// Library 依查詢 user 身份列出可見 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。
var _ Repository = (*InMemoryRepository)(nil)

View File

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

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
cp.UploadedAt = &t
}
// preset 是公用模型語意等同全平台可見visibility=public
// 在此統一標記preset 宣告區不必逐筆設 Visibility。
cp.Visibility = VisibilityPublic
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不另建。

View File

@ -0,0 +1,147 @@
/**
* ModelProfileClient
*
*
* - owner myAccess=owner +
* - / myAccess=viewer / owner
* - canDownload
* - / 404profileError EmptyState /
*
* store mock _setProfile / setState profile API
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import type { ModelProfile } from "@/lib/api/model-sharing";
import { useModelSharingStore } from "@/stores/model-sharing-store";
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
import { ModelProfileClient } from "./model-profile-client";
const ownerProfile: ModelProfile = {
id: "p1",
name: "我的模型",
targetChip: "kl520",
fileSize: 1024 * 1024,
source: "converted",
status: "ready",
visibility: "private",
owner: { id: "me", name: "我", isMe: true },
myAccess: "owner",
canDownload: true,
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-02T00:00:00Z",
};
const viewerProfile: ModelProfile = {
...ownerProfile,
id: "p2",
name: "共享模型",
visibility: "public",
owner: { id: "alice", name: "Alice", isMe: false },
myAccess: "viewer",
};
/**
* loadProfile profile store API useEffect
*/
function stubLoadProfile(profile: ModelProfile | null, error: string | null = null) {
useModelSharingStore.setState({
_mockMode: true,
loadProfile: async () => {
useModelSharingStore.setState({
profile,
profileError: error,
isProfileLoading: false,
});
},
clearProfile: () => {
/* 測試中保留注入的 profile不清空 */
},
});
}
function renderProfile(id: string) {
return render(
<LocaleProvider>
<ModelProfileClient id={id} />
</LocaleProvider>,
);
}
beforeEach(() => {
useModelSharingStore.setState({
profile: null,
isProfileLoading: false,
profileError: null,
});
});
afterEach(() => {
vi.restoreAllMocks();
// 還原 store 被 stub 的 actions
useModelSharingStore.setState(useModelSharingStore.getInitialState?.() ?? {});
});
describe("owner 版", () => {
it("顯示公開設定 + 刪除 + 下載", async () => {
stubLoadProfile(ownerProfile);
renderProfile("p1");
await waitFor(() => expect(screen.getByText("我的模型")).toBeInTheDocument());
expect(screen.getByTestId("profile-visibility")).toBeInTheDocument();
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
// 刪除鈕common.delete
expect(screen.getByText("刪除")).toBeInTheDocument();
// owner 不顯示擁有者資訊列
expect(screen.queryByTestId("model-owner-bar")).not.toBeInTheDocument();
});
});
describe("公開 / 共享版(非 owner", () => {
it("隱藏公開設定 / 刪除;顯示 owner 資訊列", async () => {
stubLoadProfile(viewerProfile);
renderProfile("p2");
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
expect(screen.queryByTestId("profile-visibility")).not.toBeInTheDocument();
// 下載仍可canDownload=true
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
// 擁有者資訊列
expect(screen.getByTestId("model-owner-bar")).toBeInTheDocument();
expect(screen.getByTestId("model-owner-bar")).toHaveTextContent("Alice");
});
it("canDownload=false → 不顯示下載鈕", async () => {
stubLoadProfile({ ...viewerProfile, canDownload: false });
renderProfile("p2");
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
expect(screen.queryByTestId("profile-download")).not.toBeInTheDocument();
});
});
describe("無權限 / 404", () => {
it("profileError=not_found → 全頁 EmptyState「找不到 / 無權限」", async () => {
stubLoadProfile(null, "not_found");
renderProfile("nope");
await waitFor(() =>
expect(screen.getByText("找不到模型或沒有存取權")).toBeInTheDocument(),
);
});
});

View File

@ -0,0 +1,337 @@
"use client";
/**
* ModelProfileClient profile owner /
*
* §6 + API §2GET /:id/profile owner-only detail
* `GET /api/models/:id/profile` + `myAccess`
* owner /
*
* §6.2
* - owner myAccess==='owner'+ + Dialog
* - / canDownload true / ModelOwnerBar
*
* §2 404 enumeration / EmptyState
*
* 沿 FAA delegated downloadlib/api/model-download endpoint
* §3download
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
getModelDownload,
ModelDownloadError,
triggerNavDownload,
} from "@/lib/api/model-download";
import { useT } from "@/lib/i18n/context";
import { useModelSharingStore } from "@/stores/model-sharing-store";
import { useModelStore } from "@/stores/model-store";
function formatFileSize(bytes: number): string {
if (!bytes) return "—";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelProfileClientProps {
id: string;
}
export function ModelProfileClient({ id }: ModelProfileClientProps) {
const t = useT();
const router = useRouter();
const profile = useModelSharingStore((s) => s.profile);
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
const profileError = useModelSharingStore((s) => s.profileError);
const loadProfile = useModelSharingStore((s) => s.loadProfile);
const clearProfile = useModelSharingStore((s) => s.clearProfile);
// 刪除沿用既有 model-storeowner-only DELETE /api/models/:id
const deleteModel = useModelStore((s) => s.deleteModel);
const [deleting, setDeleting] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
useEffect(() => {
if (id) void loadProfile(id);
return () => clearProfile();
}, [id, loadProfile, clearProfile]);
const isOwner = profile?.myAccess === "owner";
async function handleDownload() {
if (!profile || downloadBusy) return;
setDownloadBusy(true);
try {
const grant = await getModelDownload(profile.id);
triggerNavDownload(grant.downloadUrl);
toast.success(t("models.download.toast.start"), {
description: t("models.download.toast.hint"),
});
} catch (err) {
const code = err instanceof ModelDownloadError ? err.code : "unknown";
const key = `models.download.error.${code}`;
const desc = t(key);
toast.error(t("models.download.error.title"), {
description: desc === key ? t("models.download.error.unknown") : desc,
});
} finally {
setDownloadBusy(false);
}
}
async function handleDelete() {
setDeleting(true);
const ok = await deleteModel(id);
setDeleting(false);
if (ok) {
toast.success(t("common.save"));
router.push("/models");
} else {
toast.error(t("common.error"));
}
}
const backButton = (
<Link href="/models/library">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
);
// 載入中
if (isLoading && !profile) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-48 rounded-lg" />
</div>
);
}
// 無權限 / 找不到(契約:無可見性回 404合併「找不到 / 無權限」)。
if (profileError || !profile) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
{backButton}
<EmptyState
icon={SearchX}
title={t("models.profile.notFound.title")}
description={t("models.profile.notFound.description")}
action={{
label: t("models.profile.backToLibrary"),
onClick: () => router.push("/models/library"),
}}
/>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
{backButton}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{profile.targetChip.toUpperCase()}</Badge>
<Badge variant={profile.status === "ready" ? "default" : "secondary"}>
{t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
</Badge>
{profile.source !== "uploaded" && (
<Badge variant="secondary">{t(`models.source.${profile.source}`)}</Badge>
)}
{/* owner 看到自己的 visibility非 owner 看到共享標示。 */}
<ModelVisibilityBadge
visibility={profile.visibility}
sharedWithMe={!isOwner}
/>
</div>
</div>
<div className="flex items-center gap-2">
{profile.canDownload && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloadBusy}
aria-label={t("models.action.download.aria")}
data-testid="profile-download"
>
{downloadBusy ? (
<>
<Spinner size="sm" label={t("models.action.downloading")} />
{t("models.action.downloading")}
</>
) : (
<>
<DownloadIcon aria-hidden className="mr-2 size-4" />
{t("models.action.download")}
</>
)}
</Button>
)}
{/* owner-only 操作:公開設定 + 刪除 */}
{isOwner && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setVisibilityDialogOpen(true)}
data-testid="profile-visibility"
>
<Globe aria-hidden className="mr-2 size-4" />
{t("models.visibility.title")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>{profile.name}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
{/* 非 owner擁有者資訊列。 */}
{!isOwner && (
<ModelOwnerBar ownerName={profile.owner.name} sharedAt={profile.updatedAt} />
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{profile.description ? (
<p className="text-sm">{profile.description}</p>
) : (
<p className="text-muted-foreground text-sm"></p>
)}
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
<InfoRow label={t("models.size")} value={formatFileSize(profile.fileSize)} />
<InfoRow
label={t("models.createdAt")}
value={profile.createdAt ? new Date(profile.createdAt).toLocaleString() : "—"}
/>
{profile.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{profile.framework}</span>}
/>
)}
{profile.inputShape && profile.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(profile.inputShape)}
</span>
}
/>
)}
</div>
{profile.classes && profile.classes.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
<span className="text-muted-foreground text-xs">
{profile.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{profile.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{profile.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
{isOwner && (
<ModelVisibilityDialog
modelId={profile.id}
modelName={profile.name}
currentVisibility={profile.visibility}
open={visibilityDialogOpen}
onOpenChange={setVisibilityDialogOpen}
/>
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}

View File

@ -1,10 +1,16 @@
import { ModelDetailClient } from "./model-detail-client";
import { ModelProfileClient } from "./model-profile-client";
/**
* profile /models/[id]
*
* ModelProfileClientowner / GET /:id/profile
* owner owner-only ModelDetailClient
*/
export default async function ModelDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ModelDetailClient id={id} />;
return <ModelProfileClient id={id} />;
}

View File

@ -0,0 +1,129 @@
/**
* LibraryClient + cursor
*
*
* - skeleton
* - + hasMore
* - IntersectionObserver loadMore append
* -
* -
*
* IntersectionObserver jsdom mock observer
* loadMore store mock fixtures
*/
import { act, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import {
DEFAULT_LIBRARY_FILTERS,
useModelSharingStore,
} from "@/stores/model-sharing-store";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
// 可手動觸發的假 IntersectionObserver。
let intersectCallbacks: IntersectionObserverCallback[] = [];
class FakeIntersectionObserver {
constructor(cb: IntersectionObserverCallback) {
intersectCallbacks.push(cb);
}
observe() {}
unobserve() {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
root = null;
rootMargin = "";
thresholds = [];
}
function triggerIntersect() {
for (const cb of intersectCallbacks) {
cb(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
);
}
}
import { LibraryClient } from "./library-client";
function resetStore() {
useModelSharingStore.setState({
items: [],
filters: { ...DEFAULT_LIBRARY_FILTERS },
cursor: null,
hasMore: false,
isLoading: false,
isLoadingMore: false,
listError: null,
_mockMode: true,
});
}
beforeEach(() => {
intersectCallbacks = [];
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
resetStore();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function renderLibrary() {
return render(
<LocaleProvider>
<LibraryClient />
</LocaleProvider>,
);
}
describe("首屏載入", () => {
it("載入完成 → 顯示卡片網格 + 哨兵hasMore", async () => {
renderLibrary();
// mock loadFirstPage 是 async等網格出現
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
// 首頁 24 筆 → hasMore=true → 哨兵存在
expect(screen.getByTestId("library-sentinel")).toBeInTheDocument();
});
});
describe("cursor 無限捲動", () => {
it("哨兵進入視窗 → loadMore append總數增加", async () => {
renderLibrary();
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
const before = useModelSharingStore.getState().items.length;
expect(before).toBe(24);
// 模擬捲到底:哨兵進入視窗
await act(async () => {
triggerIntersect();
// 等 store loadMore 完成
await new Promise((r) => setTimeout(r, 0));
});
const after = useModelSharingStore.getState().items.length;
expect(after).toBeGreaterThan(before);
expect(after).toBe(30); // fixtures 共 30 筆,第二頁補齊
});
});
describe("空狀態", () => {
it("搜尋無結果 → 顯示搜尋空狀態", async () => {
useModelSharingStore.setState({
filters: { ...DEFAULT_LIBRARY_FILTERS, q: "zzz-no-such" },
});
renderLibrary();
await waitFor(() =>
expect(screen.getByText("找不到符合條件的模型")).toBeInTheDocument(),
);
});
});

View File

@ -0,0 +1,213 @@
"use client";
/**
* LibraryClient cursor
*
* §4 + API §1使cursor
*
* §7
* - skeleton
* - + IntersectionObserver loadMore
* - skeleton
* - EmptyState
* - EmptyState + CTA
* - +
*
* debounce 300ms §4.4local searchInput debounce store.setFilters({ q })
*/
import { useEffect, useRef, useState } from "react";
import { Boxes, SearchX, Users } from "lucide-react";
import { LibraryModelCard } from "@/components/models/library-model-card";
import { LibraryToolbar } from "@/components/models/library-toolbar";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { useT } from "@/lib/i18n/context";
import { useModelSharingStore } from "@/stores/model-sharing-store";
const SEARCH_DEBOUNCE_MS = 300;
function SkeletonGrid({ count = 8 }: { count?: number }) {
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="library-skeleton"
>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-52 rounded-lg" />
))}
</div>
);
}
export function LibraryClient() {
const t = useT();
const items = useModelSharingStore((s) => s.items);
const filters = useModelSharingStore((s) => s.filters);
const hasMore = useModelSharingStore((s) => s.hasMore);
const isLoading = useModelSharingStore((s) => s.isLoading);
const isLoadingMore = useModelSharingStore((s) => s.isLoadingMore);
const listError = useModelSharingStore((s) => s.listError);
const loadFirstPage = useModelSharingStore((s) => s.loadFirstPage);
const loadMore = useModelSharingStore((s) => s.loadMore);
const setFilters = useModelSharingStore((s) => s.setFilters);
// 搜尋框 local state受控debounce 後才推進 store filters。
const [searchInput, setSearchInput] = useState(filters.q);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 首次掛載載入首頁。
useEffect(() => {
void loadFirstPage();
}, [loadFirstPage]);
// 搜尋 debounce → setFilters會重置分頁重載
useEffect(() => {
if (searchInput === filters.q) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setFilters({ q: searchInput });
}, SEARCH_DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchInput, filters.q, setFilters]);
const { sentinelRef } = useInfiniteScroll({
enabled: hasMore && !isLoading && !isLoadingMore,
onLoadMore: loadMore,
});
const isEmpty = !isLoading && items.length === 0;
const isSearchActive =
filters.q.trim() !== "" ||
filters.targetChip !== "all" ||
filters.visibility !== "all" ||
filters.owned !== "all";
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
<div>
<h1 className="text-2xl font-bold">{t("models.library.title")}</h1>
<p className="text-muted-foreground">{t("models.library.subtitle")}</p>
</div>
<LibraryToolbar
filters={filters}
searchInput={searchInput}
onSearchChange={setSearchInput}
onFilterChange={setFilters}
/>
{/* 搜尋結果數(無障礙播報)。 */}
<p className="sr-only" role="status" aria-live="polite">
{t("models.library.resultCount").replace("{n}", String(items.length))}
</p>
{/* 首屏載入 */}
{isLoading && <SkeletonGrid />}
{/* 列表錯誤(首屏) */}
{!isLoading && listError && items.length === 0 && (
<EmptyState
icon={SearchX}
title={t("models.library.error.title")}
description={t("models.library.error.description")}
action={{
label: t("common.retry"),
onClick: () => void loadFirstPage(),
}}
/>
)}
{/* 空狀態 */}
{isEmpty && !listError && (
isSearchActive ? (
<EmptyState
icon={SearchX}
title={t("models.library.empty.search.title")}
description={t("models.library.empty.search.description")}
action={{
label: t("models.search.clearAll"),
onClick: () => {
setSearchInput("");
setFilters({
q: "",
targetChip: "all",
visibility: "all",
owned: "all",
});
},
}}
/>
) : (
<EmptyState
icon={Users}
title={t("models.library.empty.title")}
description={t("models.library.empty.description")}
/>
)
)}
{/* 卡片網格 */}
{!isLoading && items.length > 0 && (
<>
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="library-grid"
>
{items.map((model) => (
<LibraryModelCard key={model.id} model={model} />
))}
</div>
{/* 續載中 skeleton */}
{isLoadingMore && (
<div className="mt-4">
<SkeletonGrid count={4} />
</div>
)}
{/* 續載錯誤(已有資料時)→ 重試按鈕 */}
{listError && !isLoadingMore && (
<div className="mt-4 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => void loadMore()}
data-testid="library-load-more-retry"
>
{t("models.library.loadMore.retry")}
</Button>
</div>
)}
{/* 無限捲動哨兵(有下一頁且無錯誤時掛載) */}
{hasMore && !listError && (
<div
ref={sentinelRef}
className="h-4"
aria-hidden
data-testid="library-sentinel"
/>
)}
{/* 到底提示 */}
{!hasMore && (
<p
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
data-testid="library-end"
>
<Boxes aria-hidden className="size-4" />
{t("models.library.end")}
</p>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { LibraryClient } from "./library-client";
/**
* /models/library
*
* preset
* cursor api-model-sharing.md §1feature-model-sharing-design.md §4
*/
export default function ModelLibraryPage() {
return <LibraryClient />;
}

View File

@ -12,6 +12,8 @@
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Users } from "lucide-react";
import {
ModelFilters,
@ -19,6 +21,7 @@ import {
} from "@/components/models/model-filters";
import { ModelSection } from "@/components/models/model-section";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import {
type ModelSource,
@ -83,8 +86,16 @@ export default function ModelsPage() {
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
<p className="text-muted-foreground">{t("models.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Link href="/models/library">
<Button variant="outline" data-testid="models-library-link">
<Users aria-hidden className="mr-2 size-4" />
{t("models.library.link")}
</Button>
</Link>
<ModelUploadDialog />
</div>
</div>
<ModelFilters value={filter} onChange={setFilter} />
<div className="space-y-8">
{SECTION_ORDER.map((source) => (

View File

@ -20,6 +20,7 @@
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { RemoteStatus } from "@/stores/device-store";
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
className?: string;
}
/**
* components.md §10.3
* - < 60
* - < 60 X
* - < 24 X
* - 24 MM/DD HH:mm
*/
function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
const ts = Date.parse(isoString);
if (Number.isNaN(ts)) return "";
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
if (diffSec < 60) return t("remote.lastSeen.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
const d = new Date(ts);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${mm}/${dd} ${hh}:${mi}`;
}
export function RemoteDeviceBadge({
status,
lastSeenAt,

View File

@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gatingWP-C / ADR-018", () => {
).not.toBeInTheDocument();
});
});
describe("DeviceCard — 三態分色 + 註冊 UITDD §5", () => {
it("已連接未註冊online + registeredAt null→ warning 標記(文字「未註冊」+ icon不只靠色", () => {
renderCard({ ...baseDevice, remoteStatus: "online", registeredAt: null });
const badge = screen.getByTestId("unregistered-badge");
// 不只靠顏色badge 有文字「未註冊」
expect(badge).toHaveTextContent("未註冊");
// 卡片 data-tri-state 標記便於測試/樣式
expect(screen.getByTestId("device-card")).toHaveAttribute(
"data-tri-state",
"online-unregistered",
);
// 顯示「註冊」動作
expect(screen.getByTestId("device-register-btn")).toBeInTheDocument();
// 不顯示「取消註冊」
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
});
it("已連接已註冊 → 無未註冊標記,顯示「取消註冊」動作", () => {
renderCard({
...baseDevice,
remoteStatus: "online",
registeredAt: "2026-08-02T10:00:00Z",
});
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
expect(screen.getByTestId("device-card")).toHaveAttribute(
"data-tri-state",
"online-registered",
);
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
});
it("離線未註冊 → 無未註冊標記、無註冊動作(需先連線)", () => {
renderCard({ ...baseDevice, remoteStatus: "offline", registeredAt: null });
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
});
it("離線已註冊 → 顯示「取消註冊」(已註冊不論在線與否都可取消)", () => {
renderCard({
...baseDevice,
remoteStatus: "offline",
registeredAt: "2026-08-02T10:00:00Z",
});
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
});
});

View File

@ -23,6 +23,8 @@
import Link from "next/link";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { DeviceRegisterActions } from "@/components/devices/device-register-actions";
import { UnregisteredBadge } from "@/components/devices/unregistered-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -30,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { deriveTriState } from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DeviceSummary } from "@/stores/device-store";
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
const isOnline = device.remoteStatus === "online";
// WP-CADR-018serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
const hasSerial = !!device.serialNumber;
// 三態TDD §5.2online-unregistered = 已連接未註冊(第三態,走 warning 色)。
const triState = deriveTriState(device);
const isOnlineUnregistered = triState === "online-unregistered";
const isRegistered = !!device.registeredAt;
return (
<Card
data-testid="device-card"
data-remote-status={device.remoteStatus}
data-tri-state={triState}
className={cn(
"transition-colors",
// 離線裝置 opacity-75flow-offline-handling §4.1
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
// 第三態「已連接未註冊」warning 色邊框(配合角落 UnregisteredBadge 的文字+icon不只靠色
isOnlineUnregistered && "border-warning",
)}
>
<CardHeader className="pb-3">
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge。 */}
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
{t("common.manage")}
</Button>
</Link>
{/* 註冊 /
offline */}
{(isOnlineUnregistered || isRegistered) && (
<DeviceRegisterActions device={device} size="sm" />
)}
{isOnline && device.flashedModel && hasSerial && (
<Link href={`/workspace/${device.id}`}>
<Button size="sm">{t("devices.openWorkspace")}</Button>

View File

@ -0,0 +1,111 @@
"use client";
/**
* DeviceListControls + filter
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6 + filter
*
*
* - Select / /
* - filter chips / / / aria-pressed
* - state DeviceList +
*/
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { DeviceFilterKey, DeviceSortKey } from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
interface DeviceListControlsProps {
sortKey: DeviceSortKey;
filter: DeviceFilterKey;
onSortChange: (key: DeviceSortKey) => void;
onFilterChange: (filter: DeviceFilterKey) => void;
}
const FILTERS: { key: DeviceFilterKey; labelKey: string }[] = [
{ key: "all", labelKey: "devices.filter.all" },
{ key: "online-registered", labelKey: "devices.filter.onlineRegistered" },
{ key: "online-unregistered", labelKey: "devices.filter.onlineUnregistered" },
{ key: "offline", labelKey: "devices.filter.offline" },
];
const SORTS: { key: DeviceSortKey; labelKey: string }[] = [
{ key: "status", labelKey: "devices.sort.status" },
{ key: "name", labelKey: "devices.sort.name" },
{ key: "registeredAt", labelKey: "devices.sort.registeredAt" },
];
export function DeviceListControls({
sortKey,
filter,
onSortChange,
onFilterChange,
}: DeviceListControlsProps) {
const t = useT();
return (
<div
className="flex flex-wrap items-center justify-between gap-3"
data-testid="device-list-controls"
>
{/* Filter chips — role="group" + aria-pressed不只靠色選取態有邊框/底色雙變化)。 */}
<div
role="group"
aria-label={t("devices.filter.label")}
className="flex flex-wrap gap-2"
>
{FILTERS.map(({ key, labelKey }) => {
const active = filter === key;
return (
<Button
key={key}
type="button"
size="sm"
variant={active ? "default" : "outline"}
aria-pressed={active}
onClick={() => onFilterChange(key)}
data-testid={`device-filter-${key}`}
className={cn(active && "font-semibold")}
>
{t(labelKey)}
</Button>
);
})}
</div>
{/* Sort — Select */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">{t("devices.sort.label")}</span>
<Select
value={sortKey}
onValueChange={(v) => onSortChange(v as DeviceSortKey)}
>
<SelectTrigger
size="sm"
className="w-[10rem]"
data-testid="device-sort-select"
aria-label={t("devices.sort.label")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORTS.map(({ key, labelKey }) => (
<SelectItem key={key} value={key}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@ -1,45 +1,56 @@
"use client";
/**
* DeviceList + + skeleton
* DeviceList + + skeleton + /filter
*
* `local-tool/frontend/src/components/devices/device-list.tsx`
*
*
* - `.autoflow/03-design/pages.md` §5.3
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6 + filter
*
*
* - `/devices/pair`F7 Pairing scan
* - online reconnecting unknown offline error
* - / /
* - filter / / / + filter
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Link2 } from "lucide-react";
import { Link2, SearchX } from "lucide-react";
import { DeviceCard } from "@/components/devices/device-card";
import { DeviceListControls } from "@/components/devices/device-list-controls";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import {
applyDeviceListView,
type DeviceFilterKey,
type DeviceSortKey,
} from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
import type { DeviceSummary } from "@/stores/device-store";
interface DeviceListProps {
devices: DeviceSummary[];
loading?: boolean;
}
const STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
export function DeviceList({ devices, loading }: DeviceListProps) {
const t = useT();
const router = useRouter();
// 排序 / filter 狀態存 local stateP0 不持久化TDD §6.2)。
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
const [filter, setFilter] = useState<DeviceFilterKey>("all");
// 先 filter 再 sortTDD §6.3devices / 條件變動才重算。
const visible = useMemo(
() => applyDeviceListView(devices, filter, sortKey),
[devices, filter, sortKey],
);
if (loading) {
return (
<div
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
);
}
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
if (devices.length === 0) {
return (
<EmptyState
@ -74,16 +86,34 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
);
}
const sorted = [...devices].sort(
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
);
return (
<div className="space-y-4" data-testid="device-list-container">
<DeviceListControls
sortKey={sortKey}
filter={filter}
onSortChange={setSortKey}
onFilterChange={setFilter}
/>
{visible.length === 0 ? (
// filter 後 0 筆 → 與「完全沒裝置」區隔的空結果狀態(可清除 filter
<div data-testid="device-filter-empty">
<EmptyState
icon={SearchX}
title={t("devices.filter.empty.title")}
description={t("devices.filter.empty.description")}
action={{
label: t("devices.filter.empty.action"),
onClick: () => setFilter("all"),
}}
/>
</div>
) : (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list"
>
{sorted.map((device) => (
{visible.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
@ -98,5 +128,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
</span>
</Link>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,110 @@
"use client";
/**
* DeviceRegisterActions /
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5 + UI
* - `docs/autoflow/04-architecture/api/api-device-mgmt.md`register / unregister
*
*
* - online-unregistered primary
* - registeredAt != null outline
* unpairunregister 退
* 使
* - offline + null
*
*
* - register 409 ALREADY_REGISTERED
* - representative REPRESENTATIVE_DEVICE / 403 FORBIDDEN i18n退 unknown
*
* DeviceCard deriveTriState / registeredAt render
*/
import { toast } from "sonner";
import { UserCheck, UserX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import type { DeviceSummary } from "@/stores/device-store";
import { useDeviceStore } from "@/stores/device-store";
interface DeviceRegisterActionsProps {
device: DeviceSummary;
/** 動作成功後的回呼(例如刷新詳情頁);卡片就地更新則可不傳。 */
onDone?: () => void | Promise<void>;
size?: "sm" | "default";
}
/** register 失敗時把 backend code 映射到 i18n key找不到 → unknown 文案)。 */
function registerErrorDesc(t: (k: string) => string, code: string): string {
const key = `devices.register.error.${code}`;
const resolved = t(key);
return resolved === key ? t("devices.register.error.unknown") : resolved;
}
export function DeviceRegisterActions({
device,
onDone,
size = "sm",
}: DeviceRegisterActionsProps) {
const t = useT();
const registerDevice = useDeviceStore((s) => s.registerDevice);
const unregisterDevice = useDeviceStore((s) => s.unregisterDevice);
// registeringId 同時涵蓋 register / unregister 進行中;用當前 device.id 比對。
const isPending = useDeviceStore((s) => s.registeringId === device.id);
const isRegistered = !!device.registeredAt;
async function handleRegister() {
const result = await registerDevice(device.id);
if (result.ok) {
toast.success(t("devices.register.toast.success"));
await onDone?.();
} else {
toast.error(t("devices.register.error.title"), {
description: registerErrorDesc(t, result.code),
});
}
}
async function handleUnregister() {
const result = await unregisterDevice(device.id);
if (result.ok) {
toast.success(t("devices.unregister.toast.success"));
await onDone?.();
} else {
toast.error(t("devices.unregister.error.title"), {
description: registerErrorDesc(t, result.code),
});
}
}
if (isRegistered) {
return (
<Button
size={size}
variant="outline"
onClick={handleUnregister}
disabled={isPending}
data-testid="device-unregister-btn"
>
<UserX aria-hidden="true" className="mr-1.5 size-4" />
{isPending ? t("devices.unregister.pending") : t("devices.unregister.action")}
</Button>
);
}
// 未註冊只有「已連接未註冊」才可註冊offline 未註冊不顯示 → 由呼叫端 gate
return (
<Button
size={size}
onClick={handleRegister}
disabled={isPending}
data-testid="device-register-btn"
>
<UserCheck aria-hidden="true" className="mr-1.5 size-4" />
{isPending ? t("devices.register.pending") : t("devices.register.action")}
</Button>
);
}

View File

@ -0,0 +1,50 @@
"use client";
/**
* UnregisteredBadge
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.3
*
*
* - RemoteDeviceBadge badge
* online warning pill
* - warning design token--warning / --warning-foreground / --warning-subtle
* bg-yellow-* pairing / login / flash-dialog
* - design-review M2 iconTriangleAlert+
*/
import { TriangleAlert } from "lucide-react";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
export interface UnregisteredBadgeProps {
size?: "sm" | "md";
className?: string;
}
export function UnregisteredBadge({ size = "sm", className }: UnregisteredBadgeProps) {
const t = useT();
const label = t("devices.state.unregistered");
return (
<span
data-testid="unregistered-badge"
// role/aria-label讓 SR 讀出「未註冊」而非只感知一個色塊。
role="status"
aria-label={label}
className={cn(
"bg-warning-subtle text-warning-foreground border-warning inline-flex items-center gap-1 rounded-full border font-medium",
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
className,
)}
>
<TriangleAlert
aria-hidden="true"
className={cn("text-warning shrink-0", size === "sm" ? "size-3" : "size-3.5")}
/>
{label}
</span>
);
}

View File

@ -0,0 +1,154 @@
"use client";
/**
* LibraryModelCard
*
* §4.2 `LibraryModel` DTO visibility / owner / sharedWithMe /
* myAccess沿 ModelCard Card + Badge + metadata grid
*
* ModelCard
* - visibility badge + sharedWithMe ModelVisibilityBadge
* - owner owner.isMe ModelVisibilityDialog
* - receiver {ownerName} email owner.name
* - <Link> /models/{id}profile
*
* §4response owner email receiver owner.name email
*/
import { useState } from "react";
import { MoreVertical, Settings2 } from "lucide-react";
import Link from "next/link";
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { LibraryModel } from "@/lib/api/model-sharing";
import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context";
interface LibraryModelCardProps {
model: LibraryModel;
/** deterministic 相對時間用(測試傳入固定值);預設 Date.now()。 */
nowMs?: number;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function LibraryModelCard({ model, nowMs }: LibraryModelCardProps) {
const t = useT();
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()。
const [mountedNow] = useState(() => Date.now());
const isOwner = model.owner.isMe;
return (
<>
<Card className="hover:bg-accent/40 relative h-full transition-shadow hover:shadow-md">
{/* owner ⋮ 選單(絕對定位右上,避免與 <Link> 導航衝突)。 */}
{isOwner && (
<div className="absolute right-2 top-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9"
aria-label={t("models.card.menu.aria")}
data-testid="library-card-menu"
onClick={(e) => {
// 阻止冒泡到外層 <Link>。
e.preventDefault();
e.stopPropagation();
}}
>
<MoreVertical aria-hidden className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setVisibilityDialogOpen(true);
}}
data-testid="library-card-visibility"
>
<Settings2 aria-hidden className="size-4" />
{t("models.visibility.title")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<Link href={`/models/${model.id}`} data-testid="library-model-card">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2 pr-8">
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs">
{model.targetChip.toUpperCase()}
</Badge>
<ModelVisibilityBadge
visibility={model.visibility}
sharedWithMe={model.sharedWithMe}
/>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground">{t("models.size")}</p>
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("models.createdAt")}</p>
<p className="font-medium">
{model.createdAt
? new Date(model.createdAt).toLocaleDateString()
: "—"}
</p>
</div>
</div>
{/* receiver 視角:顯示分享者(契約不給 email用 owner.name。 */}
{!isOwner && (
<p
className="text-muted-foreground mt-3 text-xs"
data-testid="library-card-owner-info"
>
{t("models.sharedByName").replace("{name}", model.owner.name)}
{model.updatedAt
? ` · ${formatRelativeTime(model.updatedAt, nowMs ?? mountedNow, t)}`
: ""}
</p>
)}
</CardContent>
</Link>
</Card>
{isOwner && (
<ModelVisibilityDialog
modelId={model.id}
modelName={model.name}
currentVisibility={model.visibility}
open={visibilityDialogOpen}
onOpenChange={setVisibilityDialogOpen}
/>
)}
</>
);
}

View File

@ -0,0 +1,163 @@
"use client";
/**
* LibraryToolbar + filter +
*
* §4.3§4.5 model-sharing-store filters
*
* Mobile First wrap
* - Input + Search icon + role="searchbox"debounce parent
* - filter / /
* - filter / /
* - filter沿 targetChip
* - / /
*
* debounce local input state onSearchChange parentparent debounce
*/
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useT } from "@/lib/i18n/context";
import type { LibraryFilters } from "@/stores/model-sharing-store";
interface LibraryToolbarProps {
filters: LibraryFilters;
/** 搜尋框當前輸入(受控;由 parent 管理以便 debounce。 */
searchInput: string;
onSearchChange: (value: string) => void;
onFilterChange: (patch: Partial<LibraryFilters>) => void;
}
export function LibraryToolbar({
filters,
searchInput,
onSearchChange,
onFilterChange,
}: LibraryToolbarProps) {
const t = useT();
return (
<div
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
data-testid="library-toolbar"
role="group"
aria-label={t("models.filters.label")}
>
{/* 搜尋框 */}
<div className="relative w-full sm:max-w-xs">
<Search
aria-hidden
className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
/>
<Input
type="search"
role="searchbox"
value={searchInput}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("models.search.placeholder")}
aria-label={t("models.search.aria")}
className="pl-9 pr-9"
data-testid="library-search"
/>
{searchInput && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => onSearchChange("")}
aria-label={t("models.search.clear")}
data-testid="library-search-clear"
>
<X aria-hidden className="size-4" />
</Button>
)}
</div>
{/* 擁有關係 filter */}
<Select
value={filters.owned}
onValueChange={(v) =>
onFilterChange({ owned: v as LibraryFilters["owned"] })
}
>
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.filters.owned")}>
<SelectValue placeholder={t("models.filters.owned")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.owned.all")}</SelectItem>
<SelectItem value="mine">{t("models.filters.owned.mine")}</SelectItem>
<SelectItem value="shared">{t("models.filters.owned.shared")}</SelectItem>
</SelectContent>
</Select>
{/* 可見性 filter */}
<Select
value={filters.visibility}
onValueChange={(v) =>
onFilterChange({ visibility: v as LibraryFilters["visibility"] })
}
>
<SelectTrigger
className="h-9 w-full sm:w-40"
aria-label={t("models.filters.visibility")}
>
<SelectValue placeholder={t("models.filters.visibility")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="public">{t("models.visibility.public")}</SelectItem>
<SelectItem value="tenant">{t("models.visibility.tenant")}</SelectItem>
</SelectContent>
</Select>
{/* 晶片 filter */}
<Select
value={filters.targetChip}
onValueChange={(v) =>
onFilterChange({ targetChip: v as LibraryFilters["targetChip"] })
}
>
<SelectTrigger
className="h-9 w-full sm:w-36"
aria-label={t("models.filters.hardware")}
>
<SelectValue placeholder={t("models.filters.hardware")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="kl520">KL520</SelectItem>
<SelectItem value="kl720">KL720</SelectItem>
<SelectItem value="kl630">KL630</SelectItem>
<SelectItem value="kl730">KL730</SelectItem>
</SelectContent>
</Select>
{/* 排序 */}
<Select
value={filters.sort}
onValueChange={(v) =>
onFilterChange({ sort: v as LibraryFilters["sort"] })
}
>
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.sort.label")}>
<SelectValue placeholder={t("models.sort.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">{t("models.sort.createdAt")}</SelectItem>
<SelectItem value="name">{t("models.sort.name")}</SelectItem>
<SelectItem value="file_size">{t("models.sort.fileSize")}</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
/**
* ModelOwnerBar
*
* §6.4 owner profile {ownerName} · {time}
*
* §4response owner email owner.name email
* owner.name 沿 UserMenu avatar
*/
import { useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context";
interface ModelOwnerBarProps {
ownerName: string;
/** 共享時間ISO用 updatedAt 近似(契約無獨立 sharedAt 欄)。 */
sharedAt?: string;
nowMs?: number;
}
export function ModelOwnerBar({ ownerName, sharedAt, nowMs }: ModelOwnerBarProps) {
const t = useT();
const initial = ownerName.trim().charAt(0).toUpperCase() || "?";
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()(相對時間顯示不需即時更新)。
const [mountedNow] = useState(() => Date.now());
const relative = sharedAt
? formatRelativeTime(sharedAt, nowMs ?? mountedNow, t)
: "";
return (
<div
className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-sm"
aria-label={t("models.ownerBar.aria")}
data-testid="model-owner-bar"
>
<Avatar className="size-6">
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="truncate">
{t("models.sharedByName").replace("{name}", ownerName)}
{relative ? ` · ${relative}` : ""}
</span>
</div>
);
}

View File

@ -0,0 +1,65 @@
/**
* ModelVisibilityBadge
*
* + + shared_with_me
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import type { ModelVisibility } from "@/lib/api/model-sharing";
import { ModelVisibilityBadge } from "./model-visibility-badge";
function renderBadge(props: {
visibility: ModelVisibility;
sharedWithMe?: boolean;
sharedCount?: number;
}) {
return render(
<LocaleProvider>
<ModelVisibilityBadge {...props} />
</LocaleProvider>,
);
}
describe("ModelVisibilityBadge 三態", () => {
it("private → 顯示「私有」文字 + data-visibility=private", () => {
renderBadge({ visibility: "private" });
const badge = screen.getByTestId("model-visibility-badge");
expect(badge).toHaveAttribute("data-visibility", "private");
expect(badge).toHaveTextContent("私有");
});
it("public → 顯示「公開」文字 + data-visibility=public", () => {
renderBadge({ visibility: "public" });
const badge = screen.getByTestId("model-visibility-badge");
expect(badge).toHaveAttribute("data-visibility", "public");
expect(badge).toHaveTextContent("公開");
});
it("tenant → 顯示「同租戶」文字 + data-visibility=tenant", () => {
renderBadge({ visibility: "tenant" });
const badge = screen.getByTestId("model-visibility-badge");
expect(badge).toHaveAttribute("data-visibility", "tenant");
expect(badge).toHaveTextContent("同租戶");
});
});
describe("ModelVisibilityBadge sharedWithMe 優先", () => {
it("sharedWithMe=true → 顯示「共享給我」+ data-visibility=shared覆蓋 visibility", () => {
renderBadge({ visibility: "public", sharedWithMe: true });
const badge = screen.getByTestId("model-visibility-badge");
expect(badge).toHaveAttribute("data-visibility", "shared");
expect(badge).toHaveTextContent("共享給我");
});
});
describe("ModelVisibilityBadge sharedCount 尾綴", () => {
it("public + sharedCount=3 → 顯示「· 3 人」", () => {
renderBadge({ visibility: "public", sharedCount: 3 });
const badge = screen.getByTestId("model-visibility-badge");
expect(badge).toHaveTextContent("3 人");
});
});

View File

@ -0,0 +1,100 @@
"use client";
/**
* ModelVisibilityBadge
*
* + 滿 De1
* - privateLockowner
* - publicGlobechart-2 tint visionA 使
* - tenantBuilding2chart-1 tint
*
* sharedWithMe=true Userschart-3 tint
* model_shares visibility api-model-sharing.md §0
* visibility receiver
*
* 沿 chart-* token tint bg/10 + + /30
* model-card source badge Dark Mode token token
*/
import { Building2, Globe, Lock, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useT } from "@/lib/i18n/context";
import type { ModelVisibility } from "@/lib/api/model-sharing";
import { cn } from "@/lib/utils";
interface ModelVisibilityBadgeProps {
visibility: ModelVisibility;
/** model_shares 命中(別人分享給我)→ 優先顯示「共享給我」。 */
sharedWithMe?: boolean;
/** owner 視角:已分享給幾人(顯示在 badge 尾綴,如「公開 · 3 人」)。可選。 */
sharedCount?: number;
className?: string;
}
const VISIBILITY_META: Record<
ModelVisibility,
{ icon: typeof Lock; labelKey: string; className: string }
> = {
private: {
icon: Lock,
labelKey: "models.visibility.badge.private",
className: "text-muted-foreground border-border",
},
public: {
icon: Globe,
labelKey: "models.visibility.badge.public",
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
},
tenant: {
icon: Building2,
labelKey: "models.visibility.badge.tenant",
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
},
};
export function ModelVisibilityBadge({
visibility,
sharedWithMe = false,
sharedCount,
className,
}: ModelVisibilityBadgeProps) {
const t = useT();
// 「共享給我」優先於 visibility 顯示receiver 視角)。
if (sharedWithMe) {
return (
<Badge
variant="outline"
className={cn("border-chart-3/30 bg-chart-3/10 text-chart-3 gap-1 text-xs", className)}
data-testid="model-visibility-badge"
data-visibility="shared"
>
<Users aria-hidden className="size-3" />
{t("models.visibility.badge.sharedWithMe")}
</Badge>
);
}
const meta = VISIBILITY_META[visibility];
const Icon = meta.icon;
const label = t(meta.labelKey);
// owner 視角public/tenant 且有分享人數時,尾綴「· N 人」。
const suffix =
sharedCount && sharedCount > 0
? ` · ${t("models.visibility.badge.sharedCount").replace("{n}", String(sharedCount))}`
: "";
return (
<Badge
variant="outline"
className={cn("gap-1 text-xs", meta.className, className)}
data-testid="model-visibility-badge"
data-visibility={visibility}
>
<Icon aria-hidden className="size-3" />
{label}
{suffix}
</Badge>
);
}

View File

@ -0,0 +1,211 @@
/**
* ModelVisibilityDialog
*
*
* - / /
* - public amber
* - email inline store.addShare
* - +
* - store.updateVisibility + toast.success +
*
* store mock _setMockMode true+ shares API
* Radix Dialog / RadioGroup jsdomdialog open=true render portal
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useModelSharingStore } from "@/stores/model-sharing-store";
vi.mock("sonner", () => {
const success = vi.fn();
const error = vi.fn();
return { toast: Object.assign(vi.fn(), { success, error }) };
});
import { toast } from "sonner";
import { ModelVisibilityDialog } from "./model-visibility-dialog";
function resetStore() {
useModelSharingStore.setState({
shares: [],
isSharesLoading: false,
_mockMode: true,
// stub loadShares 為 noop測試自行以 setState 注入 shares
// 避免 body 掛載時 mock loadShares 覆蓋注入的清單。
loadShares: async () => {},
});
}
function renderDialog(currentVisibility: "private" | "public" | "tenant" = "private") {
return render(
<LocaleProvider>
<ModelVisibilityDialog
modelId="mock-model-01"
modelName="測試模型"
currentVisibility={currentVisibility}
open
onOpenChange={vi.fn()}
/>
</LocaleProvider>,
);
}
beforeEach(() => {
resetStore();
(toast.success as Mock).mockReset();
(toast.error as Mock).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("三態選項渲染", () => {
it("顯示私有 / 公開 / 同租戶三選項", async () => {
renderDialog();
await waitFor(() =>
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
);
expect(screen.getByRole("radio", { name: "私有" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "公開" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "同租戶" })).toBeInTheDocument();
});
});
describe("public 警告條", () => {
it("初始 currentVisibility=public → 顯示 amber 警告", async () => {
renderDialog("public");
await waitFor(() =>
expect(screen.getByTestId("visibility-public-warning")).toBeInTheDocument(),
);
});
it("初始 private → 不顯示警告", async () => {
renderDialog("private");
await waitFor(() =>
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
);
expect(screen.queryByTestId("visibility-public-warning")).not.toBeInTheDocument();
});
});
describe("email 加入驗證", () => {
it("格式錯 → inline 錯誤,不呼叫 addShare", async () => {
const spy = vi.spyOn(useModelSharingStore.getState(), "addShare");
renderDialog();
await waitFor(() => screen.getByTestId("share-email-input"));
fireEvent.change(screen.getByTestId("share-email-input"), {
target: { value: "not-an-email" },
});
fireEvent.click(screen.getByTestId("share-email-add"));
await waitFor(() => expect(screen.getByText("Email 格式不正確")).toBeInTheDocument());
expect(spy).not.toHaveBeenCalled();
});
it("重複 email → 提示已在清單中", async () => {
useModelSharingStore.setState({
shares: [
{ userId: "u1", email: "dup@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
],
});
renderDialog();
await waitFor(() => screen.getByTestId("share-email-input"));
fireEvent.change(screen.getByTestId("share-email-input"), {
target: { value: "dup@corp.com" },
});
fireEvent.click(screen.getByTestId("share-email-add"));
await waitFor(() => expect(screen.getByText("已在清單中")).toBeInTheDocument());
});
it("合法 email → 呼叫 addShare", async () => {
const spy = vi
.spyOn(useModelSharingStore.getState(), "addShare")
.mockResolvedValue({ ok: true });
renderDialog();
await waitFor(() => screen.getByTestId("share-email-input"));
fireEvent.change(screen.getByTestId("share-email-input"), {
target: { value: "new@corp.com" },
});
fireEvent.click(screen.getByTestId("share-email-add"));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith("mock-model-01", "new@corp.com"),
);
});
});
describe("授權清單", () => {
it("渲染既有 shares + 移除鈕", async () => {
useModelSharingStore.setState({
shares: [
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
],
});
renderDialog();
await waitFor(() => expect(screen.getByText("alice@corp.com")).toBeInTheDocument());
expect(screen.getByTestId("share-remove")).toBeInTheDocument();
});
it("點移除 → 呼叫 removeShare", async () => {
useModelSharingStore.setState({
shares: [
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
],
});
const spy = vi
.spyOn(useModelSharingStore.getState(), "removeShare")
.mockResolvedValue({ ok: true });
renderDialog();
await waitFor(() => screen.getByTestId("share-remove"));
fireEvent.click(screen.getByTestId("share-remove"));
await waitFor(() => expect(spy).toHaveBeenCalledWith("mock-model-01", "u1"));
});
});
describe("儲存", () => {
it("點儲存private無變更→ updateVisibility + toast.success", async () => {
const spy = vi
.spyOn(useModelSharingStore.getState(), "updateVisibility")
.mockResolvedValue({ ok: true });
renderDialog("private");
await waitFor(() => screen.getByTestId("visibility-save"));
fireEvent.click(screen.getByTestId("visibility-save"));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith("mock-model-01", "private"),
);
await waitFor(() => expect(toast.success).toHaveBeenCalled());
});
it("由 public 收回成 private 且有授權對象 → 先跳二次確認(不直接 save", async () => {
useModelSharingStore.setState({
shares: [
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
],
});
const spy = vi
.spyOn(useModelSharingStore.getState(), "updateVisibility")
.mockResolvedValue({ ok: true });
renderDialog("public");
await waitFor(() => screen.getByRole("radio", { name: "私有" }));
// 選「私有」
fireEvent.click(screen.getByRole("radio", { name: "私有" }));
fireEvent.click(screen.getByTestId("visibility-save"));
// 應出現二次確認updateVisibility 尚未被呼叫
await waitFor(() =>
expect(screen.getByText(/改為私有後/)).toBeInTheDocument(),
);
expect(spy).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,385 @@
"use client";
/**
* ModelVisibilityDialog owner-only
*
* §5 + API §3PATCH visibility+ shares API§4
*
*
* - RadioGroup private / public / tenant
* API visibility PRD restricted model_shares
* visibility
* - model_sharesemail + visibility
* - public amber
* - public/tenant private AlertDialog
*
* parent / profile open / onOpenChange
*/
import { useEffect, useState } from "react";
import { Globe, Lock, X } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { isValidEmail, type ModelVisibility } from "@/lib/api/model-sharing";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useModelSharingStore } from "@/stores/model-sharing-store";
interface ModelVisibilityDialogProps {
modelId: string;
modelName: string;
/** 目前的可見性(開啟時的初始值)。 */
currentVisibility: ModelVisibility;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** 三態選項(對齊 i18n key。 */
const VISIBILITY_OPTIONS: ReadonlyArray<{
value: ModelVisibility;
labelKey: string;
descKey: string;
}> = [
{ value: "private", labelKey: "models.visibility.private", descKey: "models.visibility.private.desc" },
{ value: "public", labelKey: "models.visibility.public", descKey: "models.visibility.public.desc" },
{ value: "tenant", labelKey: "models.visibility.tenant", descKey: "models.visibility.tenant.desc" },
];
/**
* Dialog key remount body props state
* setState-in-effect cascading render
*/
export function ModelVisibilityDialog(props: ModelVisibilityDialogProps) {
const { open, onOpenChange } = props;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* key 綁 modelId開啟不同模型時 body remountstate 重新以 props 初始化。 */}
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
</Dialog>
);
}
/**
* Dialog body open state props setState-in-effect
* sharesexternal sync effect
*/
function VisibilityDialogBody({
modelId,
modelName,
currentVisibility,
onOpenChange,
}: ModelVisibilityDialogProps) {
const t = useT();
const shares = useModelSharingStore((s) => s.shares);
const isSharesLoading = useModelSharingStore((s) => s.isSharesLoading);
const loadShares = useModelSharingStore((s) => s.loadShares);
const updateVisibility = useModelSharingStore((s) => s.updateVisibility);
const addShareAction = useModelSharingStore((s) => s.addShare);
const removeShareAction = useModelSharingStore((s) => s.removeShare);
const [visibility, setVisibility] = useState<ModelVisibility>(currentVisibility);
const [emailInput, setEmailInput] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [addingEmail, setAddingEmail] = useState(false);
const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false);
// 掛載時載入授權清單external syncmodelId 於本 body 生命週期固定key remount
// loadShares 是 zustand action身分穩定可安全放入 deps。
useEffect(() => {
void loadShares(modelId);
}, [modelId, loadShares]);
async function handleAddEmail() {
const email = emailInput.trim();
if (!email) return;
if (!isValidEmail(email)) {
setEmailError(t("models.visibility.emailInvalid"));
return;
}
if (shares.some((s) => s.email.toLowerCase() === email.toLowerCase())) {
setEmailError(t("models.visibility.emailDuplicate"));
return;
}
setEmailError(null);
setAddingEmail(true);
const result = await addShareAction(modelId, email);
setAddingEmail(false);
if (result.ok) {
setEmailInput("");
} else if (result.code === "not_found") {
setEmailError(t("models.visibility.userNotFound").replace("{email}", email));
} else {
setEmailError(t("models.sharing.error.generic"));
}
}
async function handleRemoveShare(userId: string) {
const result = await removeShareAction(modelId, userId);
if (!result.ok) {
toast.error(t("models.sharing.error.generic"));
}
}
/** 執行儲存(可能先過二次確認)。 */
async function doSave() {
setSaving(true);
setSaveError(null);
const result = await updateVisibility(modelId, visibility);
setSaving(false);
if (result.ok) {
toast.success(t("models.visibility.saved"));
onOpenChange(false);
} else if (result.code === "conflict") {
setSaveError(t("models.visibility.notReady"));
} else {
setSaveError(t("models.visibility.saveFailed"));
}
}
function handleSaveClick() {
// 由 public/tenant 收回成 private 且曾有授權對象 → 二次確認。
const wasBroadcast = currentVisibility === "public" || currentVisibility === "tenant";
if (visibility === "private" && wasBroadcast && shares.length > 0) {
setConfirmRevokeOpen(true);
return;
}
void doSave();
}
return (
<>
<DialogContent className="max-w-lg" data-testid="model-visibility-dialog">
<DialogHeader>
<DialogTitle>
{t("models.visibility.title")} {modelName}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm font-medium">{t("models.visibility.question")}</p>
<RadioGroup
value={visibility}
onValueChange={(v) => setVisibility(v as ModelVisibility)}
aria-label={t("models.visibility.question")}
>
{VISIBILITY_OPTIONS.map((opt) => (
<label
key={opt.value}
htmlFor={`visibility-${opt.value}`}
className="hover:bg-accent/40 flex cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid={`visibility-option-${opt.value}`}
>
<RadioGroupItem
value={opt.value}
id={`visibility-${opt.value}`}
aria-label={t(opt.labelKey)}
className="mt-0.5"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium">{t(opt.labelKey)}</span>
<span className="text-muted-foreground block text-xs">
{t(opt.descKey)}
</span>
</span>
</label>
))}
</RadioGroup>
{/* public 警告條(沿用 §2.1 amber 半語義約定)。 */}
{visibility === "public" && (
<div
className="flex items-start gap-2 rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
role="alert"
data-testid="visibility-public-warning"
>
<Globe aria-hidden className="mt-0.5 size-4 shrink-0" />
<span>{t("models.visibility.publicWarning")}</span>
</div>
)}
{/* 指定對象model_shares管理與 visibility 正交,永遠可用。 */}
<div className="space-y-2 border-t pt-4">
<Label htmlFor="share-email-input" className="text-sm font-medium">
{t("models.visibility.sharedPeopleTitle")}
</Label>
<div className="flex gap-2">
<div className="flex-1">
<Input
id="share-email-input"
type="email"
value={emailInput}
onChange={(e) => {
setEmailInput(e.target.value);
if (emailError) setEmailError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleAddEmail();
}
}}
placeholder={t("models.visibility.addEmail")}
aria-label={t("models.visibility.addEmail")}
aria-invalid={emailError ? true : undefined}
data-testid="share-email-input"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleAddEmail()}
disabled={addingEmail || !emailInput.trim()}
data-testid="share-email-add"
>
{addingEmail ? (
<Spinner size="sm" label={t("common.loading")} />
) : (
t("models.visibility.addButton")
)}
</Button>
</div>
{emailError && (
<p className="text-destructive text-xs" role="alert">
{emailError}
</p>
)}
{/* 授權清單 */}
<div
className="max-h-40 space-y-1 overflow-y-auto"
data-testid="share-list"
>
{isSharesLoading ? (
<p className="text-muted-foreground py-2 text-center text-xs">
{t("common.loading")}
</p>
) : shares.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
{t("models.visibility.noShares")}
</p>
) : (
shares.map((share) => (
<div
key={share.userId}
className="bg-muted/50 flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm"
>
<span className="truncate">{share.email}</span>
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{t("models.visibility.permissionViewDownload")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => void handleRemoveShare(share.userId)}
aria-label={t("models.visibility.removeShare").replace(
"{email}",
share.email,
)}
data-testid="share-remove"
>
<X aria-hidden className="size-4" />
</Button>
</span>
</div>
))
)}
</div>
</div>
{saveError && (
<div
className={cn(
"border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-xs",
)}
role="alert"
data-testid="visibility-save-error"
>
{saveError}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSaveClick}
disabled={saving}
data-testid="visibility-save"
>
{saving ? (
<>
<Spinner size="sm" label={t("common.loading")} />
{t("common.loading")}
</>
) : (
<>
<Lock aria-hidden className="size-4" />
{t("models.visibility.saveButton")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
{/* 收回權限二次確認 */}
<AlertDialog open={confirmRevokeOpen} onOpenChange={setConfirmRevokeOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("models.visibility.revokeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmRevokeOpen(false);
void doSave();
}}
>
{t("models.visibility.saveButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* RadioGroup Shadcn Radix RadioGroup
*
* Dialog / /
* shadcn radix-ui RadioGroup primitive package.json radix-ui ^1.4.3
*
* Radix
* - role="radiogroup" / role="radio"Arrow aria-checked
* - Item <Label htmlFor> label
*/
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-2", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,62 @@
"use client";
/**
* useInfiniteScroll hook
*
* IntersectionObserver + rootMargin
* `onLoadMore` cursor §4.6使
*
*
* - `enabled=false` hasMore=false / loading
* - `onLoadMore` ref observer
* - rootMargin `200px` 200px
* - store loadMore isLoadingMore / hasMore
* 使 observer
*
* `sentinelRef` div
*/
import { useCallback, useEffect, useRef } from "react";
interface UseInfiniteScrollOptions {
/** 是否啟用觀察(通常 = hasMore && !isLoading。 */
enabled: boolean;
/** 觸達哨兵時呼叫。 */
onLoadMore: () => void;
/** 提前量(哨兵距視窗多遠就觸發)。預設 "200px"。 */
rootMargin?: string;
}
export function useInfiniteScroll({
enabled,
onLoadMore,
rootMargin = "200px",
}: UseInfiniteScrollOptions) {
const sentinelRef = useRef<HTMLDivElement | null>(null);
const onLoadMoreRef = useRef(onLoadMore);
// 保持最新 callback避免 observer 因 callback 身分變動而重建。
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
}, [onLoadMore]);
const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
const entry = entries[0];
if (entry?.isIntersecting) {
onLoadMoreRef.current();
}
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
// 環境不支援 IntersectionObserver如部分測試環境時安全退出。
if (!enabled || !sentinel || typeof IntersectionObserver === "undefined") {
return;
}
const observer = new IntersectionObserver(handleIntersect, { rootMargin });
observer.observe(sentinel);
return () => observer.disconnect();
}, [enabled, handleIntersect, rootMargin]);
return { sentinelRef };
}

View File

@ -0,0 +1,155 @@
/**
* Model Sharing Mock Fixtures
*
* API mockapi-model-sharing.md response
* snake_case model-sharing.ts normalize
*
*
* 1. store `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` mock model-sharing-store
* 2. fixture
*
* mock owner email §4 email
* shares email owner email
*/
/** 契約 §1 library item 的原始snake_case形狀。 */
export interface RawLibraryItem {
id: string;
name: string;
description?: string;
target_chip: string;
file_size: number;
source: string;
status: string;
visibility: string;
owner: { id: string; name: string; is_me: boolean };
shared_with_me: boolean;
my_access: string;
created_at: string;
updated_at: string;
}
/** 契約 §1 分頁 response 原始形狀。 */
export interface RawLibraryPage {
items: RawLibraryItem[];
next_cursor: string | null;
has_more: boolean;
}
const NOW = "2026-08-01T00:00:00Z";
/**
* mock 30 cursor
* owner/ ownershared_with_me UI
*/
export const MOCK_LIBRARY_ITEMS: RawLibraryItem[] = Array.from({ length: 30 }).map(
(_, i) => {
const isMine = i % 3 === 0;
const visibility = isMine ? "private" : i % 3 === 1 ? "public" : "tenant";
const sharedWithMe = !isMine && i % 4 === 0;
const chips = ["kl520", "kl720", "kl630", "kl730"];
const sources = ["converted", "uploaded", "preset"];
return {
id: `mock-model-${String(i + 1).padStart(2, "0")}`,
name: `mock-model-${i + 1} ${["yolov5s", "resnet50", "mobilenet", "ssd"][i % 4]}`,
description: i % 2 === 0 ? `Mock 模型 ${i + 1} 的描述` : undefined,
target_chip: chips[i % chips.length],
file_size: (i + 1) * 1024 * 1024,
source: isMine ? "converted" : sources[i % sources.length],
status: "ready",
visibility,
owner: isMine
? { id: "me", name: "我", is_me: true }
: { id: `owner-${i}`, name: `Owner ${(i % 5) + 1}`, is_me: false },
shared_with_me: sharedWithMe,
my_access: isMine ? "owner" : sharedWithMe ? "viewer" : "viewer",
created_at: `2026-07-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`,
updated_at: NOW,
};
},
);
/**
* MOCK_LIBRARY_ITEMS query / filter / / cursor
* cursor = base64mock
*/
export function mockLibraryPage(query: {
cursor?: string;
limit?: number;
q?: string;
targetChip?: string;
source?: string;
visibility?: string;
owned?: boolean;
sort?: string;
order?: string;
}): RawLibraryPage {
let items = [...MOCK_LIBRARY_ITEMS];
// filter
if (query.q) {
const q = query.q.toLowerCase();
items = items.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
(m.description ?? "").toLowerCase().includes(q),
);
}
if (query.targetChip) {
items = items.filter((m) => m.target_chip === query.targetChip);
}
if (query.source) {
items = items.filter((m) => m.source === query.source);
}
if (query.visibility) {
items = items.filter((m) => m.visibility === query.visibility);
}
if (query.owned === true) {
items = items.filter((m) => m.owner.is_me);
} else if (query.owned === false) {
items = items.filter((m) => !m.owner.is_me);
}
// sort
const order = query.order === "asc" ? 1 : -1;
const sortKey = query.sort ?? "created_at";
items.sort((a, b) => {
if (sortKey === "name") return a.name.localeCompare(b.name) * order;
if (sortKey === "file_size") return (a.file_size - b.file_size) * order;
return a.created_at.localeCompare(b.created_at) * order;
});
// cursor 分頁
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const start = query.cursor ? Number(atob(query.cursor)) : 0;
const slice = items.slice(start, start + limit);
const nextStart = start + slice.length;
const hasMore = nextStart < items.length;
return {
items: slice,
next_cursor: hasMore ? btoa(String(nextStart)) : null,
has_more: hasMore,
};
}
/** 依 id 產生一筆 profile 原始形狀(找不到回 null模擬 404。 */
export function mockProfile(id: string): Record<string, unknown> | null {
const item = MOCK_LIBRARY_ITEMS.find((m) => m.id === id);
if (!item) return null;
return {
...item,
input_shape: [1, 3, 224, 224],
classes: ["person", "car", "dog", "cat"],
framework: "onnx",
can_download: item.my_access !== "none",
uploaded_at: item.created_at,
};
}
/** mock shares 清單owner 檢視自己模型的授權對象)。 */
export const MOCK_SHARES: Record<string, Array<{ user_id: string; email: string; role: string; created_at: string }>> = {
"mock-model-01": [
{ user_id: "u-alice", email: "alice@corp.com", role: "viewer", created_at: NOW },
{ user_id: "u-bob", email: "bob@corp.com", role: "viewer", created_at: NOW },
],
};

View File

@ -0,0 +1,180 @@
/**
* Model Sharing API Client
*
*
* - normalizesnake_case camelCasevisibility / access
* - mock cursor filterdeterministic API
* - isValidEmail
*/
import { describe, expect, it } from "vitest";
import {
isValidEmail,
normalizeLibraryModel,
normalizeLibraryPage,
normalizeProfile,
} from "./model-sharing";
import { mockLibraryPage, mockProfile, MOCK_LIBRARY_ITEMS } from "./model-sharing.mock";
describe("normalizeLibraryModel", () => {
it("snake_case → camelCase + 收斂 target_chip 大小寫", () => {
const m = normalizeLibraryModel({
id: "m1",
name: "YOLO",
target_chip: "KL520",
file_size: 2048,
source: "converted",
status: "ready",
visibility: "public",
owner: { id: "o1", name: "Alice", is_me: false },
shared_with_me: true,
my_access: "viewer",
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-02T00:00:00Z",
});
expect(m.targetChip).toBe("kl520");
expect(m.fileSize).toBe(2048);
expect(m.visibility).toBe("public");
expect(m.owner.isMe).toBe(false);
expect(m.sharedWithMe).toBe(true);
expect(m.myAccess).toBe("viewer");
});
it("非法 visibility → 收斂為 private非法 access → none", () => {
const m = normalizeLibraryModel({
id: "m2",
visibility: "weird",
my_access: "hacker",
owner: {},
});
expect(m.visibility).toBe("private");
expect(m.myAccess).toBe("none");
});
it("缺欄位 → 安全預設(不 throw", () => {
const m = normalizeLibraryModel({});
expect(m.id).toBe("");
expect(m.fileSize).toBe(0);
expect(m.source).toBe("uploaded");
expect(m.visibility).toBe("private");
});
});
describe("normalizeLibraryPage", () => {
it("解析 items + next_cursor + has_more", () => {
const page = normalizeLibraryPage({
items: [{ id: "a" }, { id: "b" }],
next_cursor: "abc",
has_more: true,
});
expect(page.items).toHaveLength(2);
expect(page.nextCursor).toBe("abc");
expect(page.hasMore).toBe(true);
});
it("無 next_cursor → null", () => {
const page = normalizeLibraryPage({ items: [], next_cursor: null, has_more: false });
expect(page.nextCursor).toBeNull();
expect(page.hasMore).toBe(false);
});
});
describe("normalizeProfile", () => {
it("解析 input_shape / classes / can_download", () => {
const p = normalizeProfile({
id: "p1",
name: "N",
target_chip: "kl720",
input_shape: [1, 3, 224, 224],
classes: ["cat", "dog"],
can_download: true,
my_access: "owner",
owner: { id: "o", name: "Me", is_me: true },
});
expect(p.inputShape).toEqual([1, 3, 224, 224]);
expect(p.classes).toEqual(["cat", "dog"]);
expect(p.canDownload).toBe(true);
expect(p.myAccess).toBe("owner");
});
it("空 classes 陣列 → undefinedUI 有值才顯示)", () => {
const p = normalizeProfile({ id: "p2", classes: [], owner: {} });
expect(p.classes).toBeUndefined();
});
});
describe("mockLibraryPage — cursor 分頁", () => {
it("首頁 limit=10 → 回 10 筆 + has_more + next_cursor", () => {
const page = mockLibraryPage({ limit: 10 });
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(page.next_cursor).not.toBeNull();
});
it("用 next_cursor 續載 → 不重複、能一路切到底", () => {
const seen = new Set<string>();
let cursor: string | undefined;
let guard = 0;
for (;;) {
const page: ReturnType<typeof mockLibraryPage> = mockLibraryPage({ limit: 7, cursor });
for (const item of page.items) {
expect(seen.has(item.id)).toBe(false); // 不重複
seen.add(item.id);
}
if (!page.has_more || !page.next_cursor) break;
cursor = page.next_cursor;
if (++guard > 20) throw new Error("cursor 未收斂");
}
expect(seen.size).toBe(MOCK_LIBRARY_ITEMS.length);
});
it("filter owned=true → 只回我的owner.is_me", () => {
const page = mockLibraryPage({ limit: 100, owned: true });
expect(page.items.every((m) => m.owner.is_me)).toBe(true);
expect(page.items.length).toBeGreaterThan(0);
});
it("filter visibility=public → 只回 public", () => {
const page = mockLibraryPage({ limit: 100, visibility: "public" });
expect(page.items.every((m) => m.visibility === "public")).toBe(true);
});
it("搜尋 q 無 match → 空頁 + has_more=false", () => {
const page = mockLibraryPage({ limit: 100, q: "zzz-no-such-model" });
expect(page.items).toHaveLength(0);
expect(page.has_more).toBe(false);
});
it("sort=name asc → 名稱遞增", () => {
const page = mockLibraryPage({ limit: 100, sort: "name", order: "asc" });
const names = page.items.map((m) => m.name);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
});
describe("mockProfile", () => {
it("存在 id → 回 profile 原始形狀(含 can_download", () => {
const raw = mockProfile("mock-model-01");
expect(raw).not.toBeNull();
expect(raw!.can_download).toBeDefined();
});
it("不存在 id → null模擬 404", () => {
expect(mockProfile("no-such")).toBeNull();
});
});
describe("isValidEmail", () => {
it.each([
["alice@corp.com", true],
["a@b.co", true],
["no-at-sign", false],
["missing@domain", false],
["@no-local.com", false],
["", false],
])("%s → %s", (email, expected) => {
expect(isValidEmail(email)).toBe(expected);
});
});

View File

@ -0,0 +1,487 @@
/**
* Model Sharing API Client visionA Cloud L
*
*
* - `docs/autoflow/04-architecture/api/api-model-sharing.md`
* - `docs/autoflow/02-prd/features/feature-model-sharing.md`
* - `docs/autoflow/03-design/feature-model-sharing-design.md`UI
*
* API shared
* - visibility `private` / `tenant` / `public`
* - model_shares visibility response `shared_with_me`
* - my_access`owner` / `editor` / `viewer` / `none`
*
* Endpoint
* 1. GET /api/models/library cursor
* 2. GET /api/models/:id/profile
* 3. PATCH /api/models/:id/visibility owner-only
* 4. GET/POST/DELETE /api/models/:id/shares owner-only
*
* response owner email §4 email
* shares email owner
*
* client API mock mock
*/
import { ApiError, api } from "@/lib/api";
/* -------------------------------------------------------------------------- */
/* Types — 對齊 api-model-sharing.md */
/* -------------------------------------------------------------------------- */
/** 模型可見性(廣播維度)。 */
export type ModelVisibility = "private" | "tenant" | "public";
/** 當前 user 對模型的有效權限(取最高)。 */
export type ModelAccess = "owner" | "editor" | "viewer" | "none";
/** 排序欄位(契約 §1。 */
export type LibrarySort = "created_at" | "name" | "file_size";
export type SortOrder = "asc" | "desc";
/** 共享庫列表項的 owner 資訊(契約:只揭露 id / name / is_me不揭露 email。 */
export interface LibraryOwner {
id: string;
name: string;
isMe: boolean;
}
/** 共享庫單一模型項(契約 §1 response items。 */
export interface LibraryModel {
id: string;
name: string;
description?: string;
targetChip: string;
fileSize: number;
source: "uploaded" | "converted" | "preset";
status: "pending" | "ready";
visibility: ModelVisibility;
owner: LibraryOwner;
/** 是否因 model_shares 命中(供 UI 標「共享給我」)。 */
sharedWithMe: boolean;
/** 當前 user 的有效權限。 */
myAccess: ModelAccess;
createdAt: string;
updatedAt: string;
}
/** 共享庫分頁結果cursor 分頁契約 §1.3)。 */
export interface LibraryPage {
items: LibraryModel[];
/** 下一頁游標(不透明);無下一頁時為 null。 */
nextCursor: string | null;
hasMore: boolean;
}
/** 共享庫查詢參數(契約 §1 query。 */
export interface LibraryQuery {
cursor?: string;
limit?: number;
sort?: LibrarySort;
order?: SortOrder;
/** 搜尋關鍵字(比對 name + description。 */
q?: string;
targetChip?: "kl520" | "kl720" | "kl630" | "kl730";
source?: "uploaded" | "converted" | "preset";
/** 僅過濾廣播類public / tenantprivate 不在共享庫語意內。 */
visibility?: "public" | "tenant";
/** true=只看我的、false=只看別人分享/公開給我的、不帶=全部。 */
owned?: boolean;
}
/** 模型 profile 頁(公開版詳情,契約 §2。 */
export interface ModelProfile {
id: string;
name: string;
description?: string;
targetChip: string;
fileSize: number;
source: "uploaded" | "converted" | "preset";
status: "pending" | "ready";
visibility: ModelVisibility;
inputShape?: number[];
classes?: string[];
framework?: string;
owner: LibraryOwner;
myAccess: ModelAccess;
/** my_access != none 時 true前端據此決定是否顯示下載鈕。 */
canDownload: boolean;
createdAt: string;
updatedAt: string;
uploadedAt?: string;
}
/** 點對點分享單一授權對象owner 檢視自己模型的授權清單)。 */
export interface ModelShare {
/** 被授權 user id。 */
userId: string;
/** 被授權 user 的顯示 emailowner 自己輸入的授權目標,可顯示)。 */
email: string;
/** 授權角色P0 固定 viewer = 可檢視 + 下載)。 */
role: "viewer" | "editor";
createdAt: string;
}
/* -------------------------------------------------------------------------- */
/* Error class */
/* -------------------------------------------------------------------------- */
/**
* UI `error.code` i18n key`models.sharing.error.<code>`
*
* code
* - `not_found`404 enumeration
* - `forbidden`403 owner visibility / shares
* - `conflict`409 ready
* - `validation_failed`400visibility / email / org tenant
* - `network_error` / `unknown`
*/
export class ModelSharingError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ModelSharingError";
this.status = status;
this.code = code;
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, ModelSharingError);
}
}
}
/** 把底層 ApiError / 一般 Error 包成 ModelSharingErrorcode 統一小寫)。 */
function wrapError(err: unknown): ModelSharingError {
if (err instanceof ModelSharingError) return err;
if (err instanceof ApiError) {
return new ModelSharingError(err.status, err.code.toLowerCase(), err.message);
}
if (err instanceof Error) {
const maybeCode = (err as unknown as { code?: unknown }).code;
const code =
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
return new ModelSharingError(0, code, err.message);
}
return new ModelSharingError(0, "unknown", String(err));
}
/* -------------------------------------------------------------------------- */
/* snake_case ⇄ camelCase 正規化(後端契約用 snake_case */
/* -------------------------------------------------------------------------- */
type Raw = Record<string, unknown>;
function asRaw(v: unknown): Raw {
return (v ?? {}) as Raw;
}
function pickStr(r: Raw, ...keys: string[]): string {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return String(r[k]);
}
return "";
}
function pickNum(r: Raw, ...keys: string[]): number {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return Number(r[k]);
}
return 0;
}
function pickBool(r: Raw, ...keys: string[]): boolean {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return Boolean(r[k]);
}
return false;
}
function normalizeVisibility(v: unknown): ModelVisibility {
return v === "public" || v === "tenant" ? v : "private";
}
function normalizeAccess(v: unknown): ModelAccess {
return v === "owner" || v === "editor" || v === "viewer" ? v : "none";
}
function normalizeOwner(raw: unknown): LibraryOwner {
const r = asRaw(raw);
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
isMe: pickBool(r, "is_me", "isMe"),
};
}
function normalizeNumberArray(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
return arr.length > 0 ? arr : undefined;
}
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v));
return arr.length > 0 ? arr : undefined;
}
export function normalizeLibraryModel(raw: unknown): LibraryModel {
const r = asRaw(raw);
const rawChip = pickStr(r, "target_chip", "targetChip");
const source = pickStr(r, "source") || "uploaded";
const status = pickStr(r, "status") || "ready";
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
description: r.description ? String(r.description) : undefined,
targetChip: rawChip.toLowerCase(),
fileSize: pickNum(r, "file_size", "fileSize"),
source: source as LibraryModel["source"],
status: status as LibraryModel["status"],
visibility: normalizeVisibility(r.visibility),
owner: normalizeOwner(r.owner),
sharedWithMe: pickBool(r, "shared_with_me", "sharedWithMe"),
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
createdAt: pickStr(r, "created_at", "createdAt"),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
};
}
export function normalizeLibraryPage(raw: unknown): LibraryPage {
const r = asRaw(raw);
const items = Array.isArray(r.items) ? r.items.map(normalizeLibraryModel) : [];
const nextCursorRaw = r.next_cursor ?? r.nextCursor;
return {
items,
nextCursor: nextCursorRaw ? String(nextCursorRaw) : null,
hasMore: pickBool(r, "has_more", "hasMore"),
};
}
export function normalizeProfile(raw: unknown): ModelProfile {
const r = asRaw(raw);
const rawChip = pickStr(r, "target_chip", "targetChip");
const source = pickStr(r, "source") || "uploaded";
const status = pickStr(r, "status") || "ready";
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
description: r.description ? String(r.description) : undefined,
targetChip: rawChip.toLowerCase(),
fileSize: pickNum(r, "file_size", "fileSize"),
source: source as ModelProfile["source"],
status: status as ModelProfile["status"],
visibility: normalizeVisibility(r.visibility),
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
classes: normalizeStringArray(r.classes),
framework: r.framework ? String(r.framework) : undefined,
owner: normalizeOwner(r.owner),
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
canDownload: pickBool(r, "can_download", "canDownload"),
createdAt: pickStr(r, "created_at", "createdAt"),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
uploadedAt: pickStr(r, "uploaded_at", "uploadedAt") || undefined,
};
}
function normalizeShare(raw: unknown): ModelShare {
const r = asRaw(raw);
const role = r.role === "editor" ? "editor" : "viewer";
return {
userId: pickStr(r, "user_id", "userId", "grantee_user_id"),
email: pickStr(r, "email", "grantee_email"),
role,
createdAt: pickStr(r, "created_at", "createdAt"),
};
}
/* -------------------------------------------------------------------------- */
/* 1. GET /api/models/library — 共享模型庫cursor 分頁) */
/* -------------------------------------------------------------------------- */
function buildLibraryQueryString(query: LibraryQuery): string {
const params = new URLSearchParams();
if (query.cursor) params.set("cursor", query.cursor);
if (query.limit !== undefined) params.set("limit", String(query.limit));
if (query.sort) params.set("sort", query.sort);
if (query.order) params.set("order", query.order);
if (query.q) params.set("q", query.q);
if (query.targetChip) params.set("target_chip", query.targetChip);
if (query.source) params.set("source", query.source);
if (query.visibility) params.set("visibility", query.visibility);
if (query.owned !== undefined) params.set("owned", String(query.owned));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
/**
* api.get wrappercookie sessionenvelopeApiError mapping
*
* 501 UI fetchModels 501
*
* @throws {ModelSharingError} 400 validation_failed /
*/
export async function fetchLibrary(query: LibraryQuery = {}): Promise<LibraryPage> {
try {
const raw = await api.get<unknown>(
`/api/models/library${buildLibraryQueryString(query)}`,
);
return normalizeLibraryPage(raw);
} catch (err) {
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
return { items: [], nextCursor: null, hasMore: false };
}
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 2. GET /api/models/:id/profile — 公開版詳情 */
/* -------------------------------------------------------------------------- */
/**
* profile 404 enumeration
*
* @throws {ModelSharingError} 404 not_found or /
*/
export async function fetchProfile(modelId: string): Promise<ModelProfile> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.get<unknown>(
`/api/models/${encodeURIComponent(modelId)}/profile`,
);
return normalizeProfile(raw);
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 3. PATCH /api/models/:id/visibility — 設定公開對象owner-only */
/* -------------------------------------------------------------------------- */
export interface UpdateVisibilityResult {
id: string;
visibility: ModelVisibility;
updatedAt: string;
}
/**
* owner
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found /
* 409 conflict ready/ 400 validation_failedvisibility / org tenant
*/
export async function updateVisibility(
modelId: string,
visibility: ModelVisibility,
): Promise<UpdateVisibilityResult> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.patch<unknown>(
`/api/models/${encodeURIComponent(modelId)}/visibility`,
{ visibility },
);
const r = asRaw(raw);
return {
id: pickStr(r, "id") || modelId,
visibility: normalizeVisibility(r.visibility),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
};
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 4. 點對點分享管理owner-only— shares CRUD */
/* -------------------------------------------------------------------------- */
/**
* owner
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found
*/
export async function fetchShares(modelId: string): Promise<ModelShare[]> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.get<unknown>(
`/api/models/${encodeURIComponent(modelId)}/shares`,
);
const r = asRaw(raw);
const list = Array.isArray(r.items)
? r.items
: Array.isArray(raw)
? (raw as unknown[])
: [];
return list.map(normalizeShare);
} catch (err) {
throw wrapError(err);
}
}
/**
* by emailP0 viewer
*
* @throws {ModelSharingError} 400 validation_failedemail / 404 user_not_found /
* 403 forbidden / 409 conflict
*/
export async function addShare(
modelId: string,
email: string,
): Promise<ModelShare> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
if (!email) {
throw new ModelSharingError(0, "validation_failed", "email is required");
}
try {
const raw = await api.post<unknown>(
`/api/models/${encodeURIComponent(modelId)}/shares`,
{ email, role: "viewer" },
);
return normalizeShare(raw);
} catch (err) {
throw wrapError(err);
}
}
/**
*
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found
*/
export async function removeShare(
modelId: string,
userId: string,
): Promise<void> {
if (!modelId || !userId) {
throw new ModelSharingError(0, "validation_failed", "modelId and userId are required");
}
try {
await api.del(
`/api/models/${encodeURIComponent(modelId)}/shares/${encodeURIComponent(userId)}`,
);
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* Email 驗證(前端即時 UX後端仍會驗 */
/* -------------------------------------------------------------------------- */
/** 寬鬆但實用的 email 格式驗證(前端即時回饋用;權威驗證在後端)。 */
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
}

View File

@ -0,0 +1,196 @@
/**
* device-state + + filter
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2 6 §6/filter
*/
import { describe, expect, it } from "vitest";
import type { DeviceSummary } from "@/stores/device-store";
import {
applyDeviceListView,
deriveTriState,
filterDevices,
isOnlineUnregistered,
sortDevices,
} from "./device-state";
/** 建一筆最小 DeviceSummary只需 deriveTriState 用到的欄位可覆寫)。 */
function makeDevice(overrides: Partial<DeviceSummary> = {}): DeviceSummary {
return {
id: "dev",
name: "KL520",
type: "kl520",
status: "connected",
remoteStatus: "online",
registeredAt: null,
...overrides,
};
}
const REGISTERED_AT = "2026-08-02T10:00:00Z";
describe("deriveTriState — 真值表(連線軸 × 註冊軸)", () => {
// TDD §5.2online×registered / online×null / offline×registered / offline×null
// + reconnecting / unknown≥6 格)
it("online + registeredAt 有值 → online-registered", () => {
expect(
deriveTriState({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
).toBe("online-registered");
});
it("online + registeredAt null → online-unregistered第三態", () => {
expect(deriveTriState({ remoteStatus: "online", registeredAt: null })).toBe(
"online-unregistered",
);
});
it("online + registeredAt undefined → online-unregistered缺欄等同未註冊", () => {
expect(deriveTriState({ remoteStatus: "online", registeredAt: undefined })).toBe(
"online-unregistered",
);
});
it("offline + registeredAt 有值 → offline離線不論註冊與否", () => {
expect(
deriveTriState({ remoteStatus: "offline", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("offline + registeredAt null → offline", () => {
expect(deriveTriState({ remoteStatus: "offline", registeredAt: null })).toBe(
"offline",
);
});
it("reconnecting → offline非 online 一律歸 offline 態)", () => {
expect(
deriveTriState({ remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("unknown → offline", () => {
expect(deriveTriState({ remoteStatus: "unknown", registeredAt: null })).toBe(
"offline",
);
});
it("error → offline", () => {
expect(
deriveTriState({ remoteStatus: "error", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("isOnlineUnregistered 僅在第三態為 true", () => {
expect(
isOnlineUnregistered({ remoteStatus: "online", registeredAt: null }),
).toBe(true);
expect(
isOnlineUnregistered({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
).toBe(false);
expect(
isOnlineUnregistered({ remoteStatus: "offline", registeredAt: null }),
).toBe(false);
});
});
describe("filterDevices — 依三態過濾", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", remoteStatus: "online", registeredAt: REGISTERED_AT }), // online-registered
makeDevice({ id: "b", remoteStatus: "online", registeredAt: null }), // online-unregistered
makeDevice({ id: "c", remoteStatus: "offline", registeredAt: null }), // offline
makeDevice({ id: "d", remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }), // offline
];
it("all → 不過濾(回原陣列)", () => {
expect(filterDevices(devices, "all")).toBe(devices);
});
it("online-registered → 只留已連接已註冊", () => {
expect(filterDevices(devices, "online-registered").map((d) => d.id)).toEqual([
"a",
]);
});
it("online-unregistered → 只留第三態", () => {
expect(filterDevices(devices, "online-unregistered").map((d) => d.id)).toEqual([
"b",
]);
});
it("offline → 留所有非 online含 reconnecting", () => {
expect(filterDevices(devices, "offline").map((d) => d.id)).toEqual(["c", "d"]);
});
});
describe("sortDevices — 三種排序鍵", () => {
it("status在線優先online→reconnecting→unknown→offline→error同狀態內比名稱", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "off", name: "Z", remoteStatus: "offline" }),
makeDevice({ id: "on-b", name: "B", remoteStatus: "online" }),
makeDevice({ id: "err", name: "A", remoteStatus: "error" }),
makeDevice({ id: "on-a", name: "A", remoteStatus: "online" }),
makeDevice({ id: "rec", name: "C", remoteStatus: "reconnecting" }),
];
expect(sortDevices(devices, "status").map((d) => d.id)).toEqual([
"on-a", // online A
"on-b", // online B
"rec", // reconnecting
"off", // offline
"err", // error
]);
});
it("namedisplayNamealias 優先localeCompare A→Z", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "1", name: "Charlie" }),
makeDevice({ id: "2", name: "Zoo", alias: "Apple" }), // alias 優先 → 排最前
makeDevice({ id: "3", name: "Bravo" }),
];
expect(sortDevices(devices, "name").map((d) => d.id)).toEqual(["2", "3", "1"]);
});
it("registeredAtdesc新在前null未註冊排最後", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "old", registeredAt: "2026-01-01T00:00:00Z" }),
makeDevice({ id: "none", registeredAt: null }),
makeDevice({ id: "new", registeredAt: "2026-08-01T00:00:00Z" }),
];
expect(sortDevices(devices, "registeredAt").map((d) => d.id)).toEqual([
"new",
"old",
"none",
]);
});
it("不 mutate 輸入陣列", () => {
const devices = [
makeDevice({ id: "1", name: "B" }),
makeDevice({ id: "2", name: "A" }),
];
const before = devices.map((d) => d.id);
sortDevices(devices, "name");
expect(devices.map((d) => d.id)).toEqual(before);
});
});
describe("applyDeviceListView — 先 filter 再 sort", () => {
it("filter 後再排序,順序正確", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", name: "Z", remoteStatus: "online", registeredAt: "2026-01-01T00:00:00Z" }),
makeDevice({ id: "b", name: "A", remoteStatus: "online", registeredAt: "2026-08-01T00:00:00Z" }),
makeDevice({ id: "c", name: "C", remoteStatus: "offline", registeredAt: null }),
];
// filter=online-registered留 a、b→ sort=nameA→Zb、a
const result = applyDeviceListView(devices, "online-registered", "name");
expect(result.map((d) => d.id)).toEqual(["b", "a"]);
});
it("filter 後 0 筆 → 回空陣列", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", remoteStatus: "offline", registeredAt: null }),
];
expect(applyDeviceListView(devices, "online-unregistered", "status")).toEqual([]);
});
});

View File

@ -0,0 +1,135 @@
/**
* device-state ×
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2
*
* = remoteStatus× registeredAt
* | | | |
* | ----------------- | --------------------------------------- | ----------------------- |
* | online-registered | remoteStatus === "online" | |
* | online-unregistered | remoteStatus === "online" | |
* | offline | remoteStatus !== "online" | |
*
* 便 6 UI
*/
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
/** 三態列舉:連線軸 × 註冊軸推導出的裝置狀態。 */
export type DeviceTriState =
| "online-registered"
| "online-unregistered"
| "offline";
/**
*
*
* - online registeredAt != null "online-registered"
* - online registeredAt == null "online-unregistered"
* - offline / reconnecting / error / unknown "offline"
*
* TDD §5.2 online offline 沿
* RemoteDeviceBadge online
*/
export function deriveTriState(
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
): DeviceTriState {
if (d.remoteStatus !== "online") return "offline";
return d.registeredAt != null ? "online-registered" : "online-unregistered";
}
/** 便利判定:此裝置是否為「已連接未註冊」第三態(可被註冊)。 */
export function isOnlineUnregistered(
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
): boolean {
return deriveTriState(d) === "online-unregistered";
}
/* -------------------------------------------------------------------------- */
/* 排序 + filterTDD §6client-side、不分頁 */
/* -------------------------------------------------------------------------- */
/** 排序鍵TDD §6.2)。`status`=依連線狀態(預設,保留既有行為)。 */
export type DeviceSortKey = "status" | "name" | "registeredAt";
/** Filter 選項TDD §6.3,依三態)。`all`=不過濾(預設)。 */
export type DeviceFilterKey =
| "all"
| "online-registered"
| "offline"
| "online-unregistered";
/**
* 沿 device-list STATUS_ORDER
* online reconnecting unknown offline error
*/
const REMOTE_STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
/** 顯示名稱alias 優先,對齊 DeviceCard 的 displayName 規則)。 */
function displayName(d: DeviceSummary): string {
return d.alias || d.name;
}
/**
* filter deriveTriState
* `all` sort
*/
export function filterDevices(
devices: DeviceSummary[],
filter: DeviceFilterKey,
): DeviceSummary[] {
if (filter === "all") return devices;
return devices.filter((d) => deriveTriState(d) === filter);
}
/**
* mutate
*
* - statusREMOTE_STATUS_ORDERlocaleCompare
* - namedisplayName localeCompareAZ
* - registeredAtdescnull
*/
export function sortDevices(
devices: DeviceSummary[],
sortKey: DeviceSortKey,
): DeviceSummary[] {
const copy = [...devices];
switch (sortKey) {
case "name":
return copy.sort((a, b) => displayName(a).localeCompare(displayName(b)));
case "registeredAt":
return copy.sort((a, b) => {
const ra = a.registeredAt ?? null;
const rb = b.registeredAt ?? null;
// null未註冊排最後兩者皆有值時比時間 desc新在前
if (ra == null && rb == null) return 0;
if (ra == null) return 1;
if (rb == null) return -1;
return rb.localeCompare(ra);
});
case "status":
default:
return copy.sort((a, b) => {
const diff =
REMOTE_STATUS_ORDER[a.remoteStatus] - REMOTE_STATUS_ORDER[b.remoteStatus];
// 同狀態內次比名稱,讓排序穩定可預期。
return diff !== 0 ? diff : displayName(a).localeCompare(displayName(b));
});
}
}
/** 先 filter 再 sortTDD §6.3:組合順序)。 */
export function applyDeviceListView(
devices: DeviceSummary[],
filter: DeviceFilterKey,
sortKey: DeviceSortKey,
): DeviceSummary[] {
return sortDevices(filterDevices(devices, filter), sortKey);
}

View File

@ -0,0 +1,38 @@
/**
* util
*
* `components/cloud/remote-device-badge.tsx` `formatRelativeTime`
* owner / 沿 i18n key
*
* components.md §10.3
* - < 60 remote.lastSeen.justNow
* - < 60 X remote.lastSeen.minutesAgo
* - < 24 X remote.lastSeen.hoursAgo
* - 24 MM/DD HH:mm
*/
/**
* @param isoString ISO 8601
* @param nowMs ms caller 便 deterministic
* @param t i18n remote.lastSeen.* key
*/
export function formatRelativeTime(
isoString: string,
nowMs: number,
t: (k: string) => string,
): string {
const ts = Date.parse(isoString);
if (Number.isNaN(ts)) return "";
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
if (diffSec < 60) return t("remote.lastSeen.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
const d = new Date(ts);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${mm}/${dd} ${hh}:${mi}`;
}

View File

@ -180,6 +180,43 @@ export const en: Dictionary = {
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
"devices.remove.error.unknown": "Something went wrong. Please try again.",
// ── Devices: tri-state (connection × registration) ──
"devices.state.unregistered": "Unregistered",
// ── Devices: register / unregister ──
"devices.register.action": "Register",
"devices.register.pending": "Registering…",
"devices.register.toast.success": "Device registered",
"devices.register.error.title": "Couldn't register device",
"devices.register.error.ALREADY_REGISTERED": "This device is already registered.",
"devices.register.error.REPRESENTATIVE_DEVICE":
"This kind of device can't be registered or unregistered.",
"devices.register.error.FORBIDDEN": "You don't have permission to register this device.",
"devices.register.error.NOT_FOUND": "This device no longer exists.",
"devices.register.error.unknown": "Something went wrong. Please try again.",
// Unregister (return to unregistered state) — NOT the same as removing/unpairing the device.
"devices.unregister.action": "Unregister",
"devices.unregister.pending": "Unregistering…",
"devices.unregister.hint":
"This returns the device to an unregistered state. It stays in your list and isn't removed.",
"devices.unregister.toast.success": "Device unregistered",
"devices.unregister.error.title": "Couldn't unregister device",
// ── Devices: sort + filter ──
"devices.sort.label": "Sort by",
"devices.sort.status": "Status",
"devices.sort.name": "Name",
"devices.sort.registeredAt": "Registered",
"devices.filter.label": "Filter devices",
"devices.filter.all": "All",
"devices.filter.onlineRegistered": "Connected",
"devices.filter.onlineUnregistered": "Connected, unregistered",
"devices.filter.offline": "Disconnected",
"devices.filter.empty.title": "No devices match this filter",
"devices.filter.empty.description":
"Try a different filter, or clear it to see all your devices.",
"devices.filter.empty.action": "Clear filter",
// ── Devices: flash (load model to device) ──
"devices.flash.flashModel": "Load model",
"devices.flash.flashToDevice": "Load a model to this device",
@ -309,6 +346,78 @@ export const en: Dictionary = {
"models.download.error.busy": "A download is already in progress, please wait.",
"models.download.error.unknown": "Download failed, please try again later.",
// ── Model Sharing ──
// Shared model library list
"models.library.title": "Shared Model Library",
"models.library.subtitle": "Browse models you can access: yours, public ones, and those shared with you",
"models.library.empty.title": "No shared models yet",
"models.library.empty.description": "Models shared with you or made public will appear here",
"models.library.empty.search.title": "No models match your criteria",
"models.library.empty.search.description": "Try other keywords or clear the filters",
"models.library.error.title": "Failed to load the library",
"models.library.error.description": "Please try again later",
"models.library.loadMore.retry": "Failed to load. Click to retry",
"models.library.resultCount": "Found {n} models",
"models.library.end": "All models shown",
"models.library.link": "Shared library",
// Search
"models.search.placeholder": "Search model name…",
"models.search.aria": "Search models",
"models.search.clear": "Clear search",
"models.search.clearAll": "Clear all filters",
// Filters
"models.filters.owned": "Ownership",
"models.filters.owned.all": "All",
"models.filters.owned.mine": "My models",
"models.filters.owned.shared": "Shared with me",
"models.filters.visibility": "Visibility",
// Sort
"models.sort.label": "Sort",
"models.sort.createdAt": "Newest",
"models.sort.name": "Name",
"models.sort.fileSize": "File size",
// Visibility badge (three states + shared)
"models.visibility.badge.private": "Private",
"models.visibility.badge.public": "Public",
"models.visibility.badge.tenant": "Same tenant",
"models.visibility.badge.sharedWithMe": "Shared with me",
"models.visibility.badge.sharedCount": "{n} people",
// Card owner menu
"models.card.menu.aria": "Model actions menu",
// Receiver info row (contract does not expose email, use name)
"models.sharedByName": "Shared by {name}",
"models.ownerBar.aria": "Model owner info",
// Visibility dialog
"models.visibility.title": "Visibility",
"models.visibility.question": "Who can access this model?",
"models.visibility.private": "Private",
"models.visibility.private.desc": "Only you",
"models.visibility.public": "Public",
"models.visibility.public.desc": "All visionA users",
"models.visibility.tenant": "Same tenant",
"models.visibility.tenant.desc": "Members of your organization",
"models.visibility.sharedPeopleTitle": "Specific people (additionally shared)",
"models.visibility.addEmail": "Add by email",
"models.visibility.addButton": "Add",
"models.visibility.noShares": "Not shared with anyone yet",
"models.visibility.permissionViewDownload": "View + download",
"models.visibility.removeShare": "Remove {email}",
"models.visibility.publicWarning": "Once public, all visionA users can view and download this model",
"models.visibility.saveButton": "Save changes",
"models.visibility.saved": "Visibility updated",
"models.visibility.saveFailed": "Failed to save, please retry",
"models.visibility.notReady": "Model is not ready and cannot be made public",
"models.visibility.emailInvalid": "Invalid email format",
"models.visibility.emailDuplicate": "Already in the list",
"models.visibility.userNotFound": "User {email} not found",
"models.visibility.revokeConfirm": "Setting to private revokes access for shared users. Continue?",
// Profile page
"models.profile.notFound.title": "Model not found or no access",
"models.profile.notFound.description": "This model does not exist, is not public, or was not shared with you",
"models.profile.backToLibrary": "Back to shared library",
// Generic sharing error
"models.sharing.error.generic": "Operation failed, please try again later",
// ── Workspace ──
"workspace.title": "Workspace",
"workspace.subtitle": "Select an online device to start inference",

View File

@ -181,6 +181,41 @@ export const zhHant: Dictionary = {
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
// ── Devices: 三態(連線 × 註冊) ──
"devices.state.unregistered": "未註冊",
// ── Devices: 註冊 / 取消註冊 ──
"devices.register.action": "註冊",
"devices.register.pending": "註冊中…",
"devices.register.toast.success": "已註冊裝置",
"devices.register.error.title": "註冊裝置失敗",
"devices.register.error.ALREADY_REGISTERED": "此裝置已註冊",
"devices.register.error.REPRESENTATIVE_DEVICE": "這類裝置無法註冊或取消註冊",
"devices.register.error.FORBIDDEN": "你沒有權限註冊此裝置",
"devices.register.error.NOT_FOUND": "此裝置已不存在",
"devices.register.error.unknown": "發生錯誤,請稍後再試",
// 取消註冊(退回未註冊態)— 與「移除裝置(解除配對)」不同,不會刪掉裝置。
"devices.unregister.action": "取消註冊",
"devices.unregister.pending": "取消註冊中…",
"devices.unregister.hint":
"這會把裝置退回未註冊狀態,裝置仍保留在清單中,不會被移除。",
"devices.unregister.toast.success": "已取消註冊",
"devices.unregister.error.title": "取消註冊失敗",
// ── Devices: 排序 + 篩選 ──
"devices.sort.label": "排序方式",
"devices.sort.status": "狀態",
"devices.sort.name": "名稱",
"devices.sort.registeredAt": "註冊時間",
"devices.filter.label": "篩選裝置",
"devices.filter.all": "全部",
"devices.filter.onlineRegistered": "已連接",
"devices.filter.onlineUnregistered": "已連接未註冊",
"devices.filter.offline": "未連接",
"devices.filter.empty.title": "沒有符合此篩選條件的裝置",
"devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。",
"devices.filter.empty.action": "清除篩選",
// ── Devices: flash載入模型到裝置 ──
"devices.flash.flashModel": "載入模型",
"devices.flash.flashToDevice": "載入模型到此裝置",
@ -301,6 +336,78 @@ export const zhHant: Dictionary = {
"models.download.error.busy": "已有下載進行中,請稍候",
"models.download.error.unknown": "下載失敗,請稍後再試",
// ── 模型共享Model Sharing──
// 共享模型庫列表
"models.library.title": "共享模型庫",
"models.library.subtitle": "瀏覽你可存取的模型:你的、公開的、以及別人分享給你的",
"models.library.empty.title": "還沒有可存取的共享模型",
"models.library.empty.description": "當同事把模型分享給你、或有公開模型時,會出現在這裡",
"models.library.empty.search.title": "找不到符合條件的模型",
"models.library.empty.search.description": "試試其他關鍵字或清除篩選條件",
"models.library.error.title": "載入模型庫失敗",
"models.library.error.description": "請稍後再試",
"models.library.loadMore.retry": "載入失敗,點擊重試",
"models.library.resultCount": "找到 {n} 個模型",
"models.library.end": "已顯示全部模型",
"models.library.link": "共享模型庫",
// 搜尋
"models.search.placeholder": "搜尋模型名稱…",
"models.search.aria": "搜尋模型",
"models.search.clear": "清除搜尋",
"models.search.clearAll": "清除所有篩選",
// filter
"models.filters.owned": "擁有關係",
"models.filters.owned.all": "全部",
"models.filters.owned.mine": "我的模型",
"models.filters.owned.shared": "共享給我",
"models.filters.visibility": "可見性",
// 排序
"models.sort.label": "排序",
"models.sort.createdAt": "最新建立",
"models.sort.name": "名稱",
"models.sort.fileSize": "檔案大小",
// visibility badge三態 + 共享)
"models.visibility.badge.private": "私有",
"models.visibility.badge.public": "公開",
"models.visibility.badge.tenant": "同租戶",
"models.visibility.badge.sharedWithMe": "共享給我",
"models.visibility.badge.sharedCount": "{n} 人",
// 卡片 owner 選單
"models.card.menu.aria": "模型操作選單",
// receiver 資訊列(契約不揭露 email用名稱
"models.sharedByName": "由 {name} 共享",
"models.ownerBar.aria": "模型擁有者資訊",
// 公開設定 Dialog
"models.visibility.title": "公開設定",
"models.visibility.question": "誰可以看到並使用這個模型?",
"models.visibility.private": "私有",
"models.visibility.private.desc": "只有你自己",
"models.visibility.public": "公開",
"models.visibility.public.desc": "所有 visionA 使用者",
"models.visibility.tenant": "同租戶",
"models.visibility.tenant.desc": "與你同組織的成員",
"models.visibility.sharedPeopleTitle": "指定對象(額外分享給特定人)",
"models.visibility.addEmail": "輸入 email 加入",
"models.visibility.addButton": "加入",
"models.visibility.noShares": "尚未分享給任何人",
"models.visibility.permissionViewDownload": "可檢視 + 下載",
"models.visibility.removeShare": "移除 {email}",
"models.visibility.publicWarning": "公開後,所有 visionA 使用者都能檢視並下載此模型",
"models.visibility.saveButton": "儲存變更",
"models.visibility.saved": "已更新公開設定",
"models.visibility.saveFailed": "儲存失敗,請重試",
"models.visibility.notReady": "模型尚未就緒,無法公開",
"models.visibility.emailInvalid": "Email 格式不正確",
"models.visibility.emailDuplicate": "已在清單中",
"models.visibility.userNotFound": "找不到使用者 {email}",
"models.visibility.revokeConfirm": "改為私有後,已分享的對象將無法再存取,確定要繼續嗎?",
// profile 頁
"models.profile.notFound.title": "找不到模型或沒有存取權",
"models.profile.notFound.description": "這個模型不存在、未公開,或未分享給你",
"models.profile.backToLibrary": "返回共享模型庫",
// 通用共享錯誤
"models.sharing.error.generic": "操作失敗,請稍後再試",
// ── Workspace ──
"workspace.title": "推論工作區",
"workspace.subtitle": "選擇已線上的裝置開始推論",

View File

@ -23,6 +23,7 @@ beforeEach(() => {
connectingId: null,
disconnectingId: null,
unpairingId: null,
registeringId: null,
error: null,
});
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -412,3 +413,253 @@ describe("useDeviceStore.unpairDevice", () => {
expect(useDeviceStore.getState().unpairingId).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* 註冊軸normalizeDevice registeredAt + register / unregister actions */
/* -------------------------------------------------------------------------- */
describe("useDeviceStore — registeredAt 正規化TDD §5.1", () => {
it("registered_atsnake/ registeredAtcamel有值 → 正確帶入;缺欄 → null", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: [
// snake_case後端實際形狀
{
id: "dev-1",
name: "A",
type: "kl520",
status: "connected",
registered_at: "2026-08-02T10:00:00Z",
},
// camelCase 容錯
{
id: "dev-2",
name: "B",
type: "kl520",
status: "connected",
registeredAt: "2026-08-01T00:00:00Z",
},
// 缺欄位(未註冊 / 舊資料)→ null
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
// 明確 null → null
{
id: "dev-4",
name: "D",
type: "kl520",
status: "connected",
registered_at: null,
},
],
}),
);
await useDeviceStore.getState().fetchDevices();
const { devices } = useDeviceStore.getState();
expect(devices[0]?.registeredAt).toBe("2026-08-02T10:00:00Z");
expect(devices[1]?.registeredAt).toBe("2026-08-01T00:00:00Z");
expect(devices[2]?.registeredAt).toBeNull();
expect(devices[3]?.registeredAt).toBeNull();
});
});
describe("useDeviceStore.registerDevice", () => {
const unregistered = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected" as const,
remoteStatus: "online" as const,
registeredAt: null,
};
it("成功時打對 register endpointUUID、就地更新 registeredAt、回 { ok:true }", async () => {
useDeviceStore.setState({
devices: [unregistered, { ...unregistered, id: "dev-2" }],
selectedDevice: { ...unregistered },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: {
id: "dev-1",
name: "KL520",
device_type: "kl520",
status: "connected",
remote_status: "online",
registered_at: "2026-08-02T10:00:00Z",
},
}),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toEqual({ ok: true });
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
expect(calledUrl).toContain("/api/devices/dev-1/register");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
const state = useDeviceStore.getState();
// 就地更新該筆 registeredAt不移除 list
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBe(
"2026-08-02T10:00:00Z",
);
// 其他裝置不受影響
expect(state.devices.find((d) => d.id === "dev-2")?.registeredAt).toBeNull();
// selectedDevice 同步更新
expect(state.selectedDevice?.registeredAt).toBe("2026-08-02T10:00:00Z");
expect(state.registeringId).toBeNull();
});
it("就地 merge 只覆寫 registeredAt不把本地既有欄位清成 null後端 omitempty 防禦)", async () => {
useDeviceStore.setState({
devices: [{ ...unregistered, firmwareVersion: "2.3.1" }],
});
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
// 後端回應缺 firmware_versionomitempty
jsonResponse({
success: true,
data: {
id: "dev-1",
name: "KL520",
status: "connected",
registered_at: "2026-08-02T10:00:00Z",
},
}),
);
await useDeviceStore.getState().registerDevice("dev-1");
const d = useDeviceStore.getState().devices[0];
expect(d?.registeredAt).toBe("2026-08-02T10:00:00Z");
// 本地既有 firmwareVersion 不被覆寫成 null
expect(d?.firmwareVersion).toBe("2.3.1");
});
it("呼叫期間 registeringId 設為該 idloading 態)", async () => {
let observed: string | null = "not-set";
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
observed = useDeviceStore.getState().registeringId;
return jsonResponse({
success: true,
data: { id: "dev-1", registered_at: "2026-08-02T10:00:00Z" },
});
});
await useDeviceStore.getState().registerDevice("dev-1");
expect(observed).toBe("dev-1");
expect(useDeviceStore.getState().registeringId).toBeNull();
});
it("409 ALREADY_REGISTERED → 回 { ok:false, code:'ALREADY_REGISTERED' },不改 list", async () => {
useDeviceStore.setState({ devices: [unregistered] });
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{
success: false,
error: { code: "ALREADY_REGISTERED", message: "device already registered" },
},
409,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "ALREADY_REGISTERED" });
// list 不變registeredAt 仍 null
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBeNull();
expect(useDeviceStore.getState().registeringId).toBeNull();
});
it("409 REPRESENTATIVE_DEVICErepresentative→ 回 { ok:false, code:'REPRESENTATIVE_DEVICE' }", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{
success: false,
error: { code: "REPRESENTATIVE_DEVICE", message: "representative" },
},
409,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "REPRESENTATIVE_DEVICE" });
});
it("403 FORBIDDEN非 owner→ 回 { ok:false, code:'FORBIDDEN' }", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
403,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
});
});
describe("useDeviceStore.unregisterDevice", () => {
const registered = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected" as const,
remoteStatus: "online" as const,
registeredAt: "2026-08-02T10:00:00Z",
};
it("成功時打對 unregister endpoint、清 registeredAt、**保留 list**(不移除)", async () => {
useDeviceStore.setState({
devices: [registered, { ...registered, id: "dev-2" }],
selectedDevice: { ...registered },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: { id: "dev-1", name: "KL520", status: "connected", registered_at: null },
}),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toEqual({ ok: true });
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
expect(calledUrl).toContain("/api/devices/dev-1/unregister");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
const state = useDeviceStore.getState();
// 關鍵device 仍在 list與 unpair 的差異),只是 registeredAt 清 null
expect(state.devices.map((d) => d.id)).toEqual(["dev-1", "dev-2"]);
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBeNull();
expect(state.selectedDevice?.registeredAt).toBeNull();
expect(state.registeringId).toBeNull();
});
it("冪等:已未註冊再 unregister後端回 200 null→ ok:truelist 保留", async () => {
useDeviceStore.setState({
devices: [{ ...registered, registeredAt: null }],
});
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ success: true, data: { id: "dev-1", registered_at: null } }),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toEqual({ ok: true });
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
});
it("403 FORBIDDEN → 回 { ok:false, code:'FORBIDDEN' }list 不變", async () => {
useDeviceStore.setState({ devices: [registered] });
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
403,
),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
// 失敗時 registeredAt 不變
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBe(
"2026-08-02T10:00:00Z",
);
expect(useDeviceStore.getState().registeringId).toBeNull();
});
});

View File

@ -89,6 +89,13 @@ export interface DeviceSummary {
status: DeviceHardwareStatus;
/** 遠端 tunnel 狀態flow-offline-handling.md §2 新增) */
remoteStatus: RemoteStatus;
/**
* ISO 8601 feature-device-mgmt-tdd.md §5
* JSON key `registered_at`omitemptynull / =
* lib/device-state.ts deriveTriStateonline registeredAt != null
* online null
*/
registeredAt?: string | null;
/** ISO 8601最後心跳時間 */
lastSeenAt?: string | null;
firmwareVersion?: string | null;
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
status: coerceHardwareStatus(pick<string>("status")),
remoteStatus:
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
// 註冊軸TDD §5.1):後端回 registered_atomitempty— 缺欄 / null 皆視為未註冊。
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
firmwareVersion:
pick<string>("firmware_version", "firmwareVersion") ?? null,
@ -179,6 +189,20 @@ export type UnpairResult =
| { ok: true }
| { ok: false; code: string; message: string };
/**
* register / unregister action
*
* unpair code boolean UI toast
* - register409 `ALREADY_REGISTERED`409 `REPRESENTATIVE_DEVICE`representative
* 403 `FORBIDDEN` owner404 `NOT_FOUND` api-device-mgmt.md §3
* - unregister 200 403 / 404 / representativeREPRESENTATIVE_DEVICE
*
* store device registeredAt refetch unpair
*/
export type RegisterResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface DeviceState {
devices: DeviceSummary[];
selectedDevice: Device | null;
@ -188,6 +212,8 @@ interface DeviceState {
disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */
unpairingId: string | null;
/** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */
registeringId: string | null;
error: string | null;
/** 呼叫 `GET /api/devices` */
@ -207,6 +233,19 @@ interface DeviceState {
disconnectDevice: (serialNumber: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */
unpairDevice: (id: string) => Promise<UnpairResult>;
/**
* `POST /api/devices/:id/register`UUID DB
* registered_at NULL now()
* registeredAt refetch
* connect connect serial register UUIDADR-018 FE-ADB UUID
*/
registerDevice: (id: string) => Promise<RegisterResult>;
/**
* `POST /api/devices/:id/unregister`UUID
* 退registered_at NULL**** token
* unpairunregister registeredAtdevice
*/
unregisterDevice: (id: string) => Promise<RegisterResult>;
/** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
@ -220,6 +259,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
connectingId: null,
disconnectingId: null,
unpairingId: null,
registeringId: null,
error: null,
fetchDevices: async () => {
@ -317,6 +357,65 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
}
},
registerDevice: async (id) => {
set({ registeringId: id, error: null });
try {
// 契約POST /api/devices/:id/register → 回更新後的 DeviceListItemregistered_at 非 null
// api.post 已 unwrap envelope 的 datanormalizeDevice 讀出 registeredAt。
const raw = await api.post<unknown>(
`/api/devices/${encodeURIComponent(id)}/register`,
);
const updated = normalizeDevice(raw);
// 就地更新該筆 registeredAt避免 refetch 延遲,比照 unpair 就地移除範式)。
// 後端回應可能缺部分欄位omitempty→ 只 merge registeredAt其餘沿用本地既有值
// 避免把本地已知欄位(如 firmwareVersion覆寫成 null。
set((state) => ({
devices: state.devices.map((d) =>
d.id === id ? { ...d, registeredAt: updated.registeredAt } : d,
),
selectedDevice:
state.selectedDevice?.id === id
? { ...state.selectedDevice, registeredAt: updated.registeredAt }
: state.selectedDevice,
registeringId: null,
}));
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ registeringId: null, error: message });
return { ok: false, code, message };
}
},
unregisterDevice: async (id) => {
set({ registeringId: id, error: null });
try {
// 契約POST /api/devices/:id/unregister → registered_at → null**保留裝置列**(不軟刪)。
// 冪等:已未註冊也回 200。回更新後 DeviceListItemregistered_at=null
await api.post<unknown>(
`/api/devices/${encodeURIComponent(id)}/unregister`,
);
// 就地把該筆 registeredAt 清成 nulldevice 仍留在 list不移除——與 unpair 的關鍵差異)。
set((state) => ({
devices: state.devices.map((d) =>
d.id === id ? { ...d, registeredAt: null } : d,
),
selectedDevice:
state.selectedDevice?.id === id
? { ...state.selectedDevice, registeredAt: null }
: state.selectedDevice,
registeringId: null,
}));
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ registeringId: null, error: message });
return { ok: false, code, message };
}
},
_setDevices: (devices) => set({ devices }),
_setSelected: (selectedDevice) => set({ selectedDevice }),
}));

View File

@ -0,0 +1,183 @@
/**
* Model Sharing Store mock deterministic
*
*
* - loadFirstPage cursor / hasMore
* - loadMoreappend hasMore=false
* - loadMore cursor /
* - setFilters
* - loadProfilemock / 404
* - updateVisibility / addShare / removeShare
* - filtersToQueryUI filters API query
*/
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_LIBRARY_FILTERS,
filtersToQuery,
LIBRARY_PAGE_SIZE,
useModelSharingStore,
} from "./model-sharing-store";
function resetStore() {
useModelSharingStore.setState({
items: [],
filters: { ...DEFAULT_LIBRARY_FILTERS },
cursor: null,
hasMore: false,
isLoading: false,
isLoadingMore: false,
listError: null,
profile: null,
isProfileLoading: false,
profileError: null,
shares: [],
isSharesLoading: false,
_mockMode: true, // 測試一律走 mock不打真實 API
});
}
beforeEach(() => {
resetStore();
});
describe("loadFirstPage", () => {
it("載入首頁 → items = PAGE_SIZE、hasMore=true、cursor 非空", async () => {
await useModelSharingStore.getState().loadFirstPage();
const s = useModelSharingStore.getState();
expect(s.items).toHaveLength(LIBRARY_PAGE_SIZE);
expect(s.hasMore).toBe(true);
expect(s.cursor).not.toBeNull();
expect(s.isLoading).toBe(false);
});
});
describe("loadMore — cursor 無限捲動", () => {
it("續載 → append 下一頁且不與首頁重複", async () => {
const store = useModelSharingStore.getState();
await store.loadFirstPage();
const firstIds = useModelSharingStore.getState().items.map((m) => m.id);
await useModelSharingStore.getState().loadMore();
const all = useModelSharingStore.getState().items;
// 續載後總數 > 首頁
expect(all.length).toBeGreaterThan(firstIds.length);
// 無重複 id
expect(new Set(all.map((m) => m.id)).size).toBe(all.length);
});
it("一路 loadMore 到底 → hasMore=false、涵蓋全部 30 筆", async () => {
await useModelSharingStore.getState().loadFirstPage();
let guard = 0;
while (useModelSharingStore.getState().hasMore) {
await useModelSharingStore.getState().loadMore();
if (++guard > 10) throw new Error("loadMore 未收斂");
}
const s = useModelSharingStore.getState();
expect(s.hasMore).toBe(false);
expect(s.items).toHaveLength(30); // mock fixtures 共 30 筆
});
it("重入防護:無 cursor未載入首頁→ loadMore 不改變 items", async () => {
await useModelSharingStore.getState().loadMore();
expect(useModelSharingStore.getState().items).toHaveLength(0);
});
});
describe("setFilters", () => {
it("設 owned=mine → 重置分頁並只留我的模型", async () => {
await useModelSharingStore.getState().loadFirstPage();
useModelSharingStore.getState().setFilters({ owned: "mine" });
// setFilters 內部呼叫 loadFirstPageasync等 microtask
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
const s = useModelSharingStore.getState();
expect(s.filters.owned).toBe("mine");
expect(s.items.every((m) => m.owner.isMe)).toBe(true);
});
});
describe("loadProfile", () => {
it("命中 mock id → 設 profile", async () => {
await useModelSharingStore.getState().loadProfile("mock-model-01");
const s = useModelSharingStore.getState();
expect(s.profile?.id).toBe("mock-model-01");
expect(s.profileError).toBeNull();
});
it("不存在 id → profileError=not_found模擬 404 防 enumeration", async () => {
await useModelSharingStore.getState().loadProfile("no-such-model");
const s = useModelSharingStore.getState();
expect(s.profile).toBeNull();
expect(s.profileError).toBe("not_found");
});
});
describe("公開設定樂觀更新", () => {
it("updateVisibility → 更新 items 中對應項與 profile", async () => {
await useModelSharingStore.getState().loadProfile("mock-model-01");
const result = await useModelSharingStore
.getState()
.updateVisibility("mock-model-01", "public");
expect(result.ok).toBe(true);
expect(useModelSharingStore.getState().profile?.visibility).toBe("public");
});
it("addShare → 加入 sharesremoveShare → 移除", async () => {
await useModelSharingStore.getState().loadShares("mock-model-01");
const before = useModelSharingStore.getState().shares.length;
const add = await useModelSharingStore
.getState()
.addShare("mock-model-01", "new@corp.com");
expect(add.ok).toBe(true);
expect(useModelSharingStore.getState().shares).toHaveLength(before + 1);
const added = useModelSharingStore
.getState()
.shares.find((s) => s.email === "new@corp.com");
const remove = await useModelSharingStore
.getState()
.removeShare("mock-model-01", added!.userId);
expect(remove.ok).toBe(true);
expect(useModelSharingStore.getState().shares).toHaveLength(before);
});
});
describe("filtersToQuery", () => {
it("all / 空值省略mine → owned=true", () => {
const q = filtersToQuery({
...DEFAULT_LIBRARY_FILTERS,
owned: "mine",
q: " hello ",
});
expect(q.owned).toBe(true);
expect(q.q).toBe("hello"); // trim
expect(q.targetChip).toBeUndefined(); // all → 省略
expect(q.visibility).toBeUndefined();
expect(q.limit).toBe(LIBRARY_PAGE_SIZE);
});
it("shared → owned=false具體 filter 帶上", () => {
const q = filtersToQuery({
q: "",
targetChip: "kl720",
visibility: "public",
owned: "shared",
sort: "name",
order: "asc",
});
expect(q.owned).toBe(false);
expect(q.targetChip).toBe("kl720");
expect(q.visibility).toBe("public");
expect(q.sort).toBe("name");
expect(q.order).toBe("asc");
});
it("帶 cursor → query 含 cursor", () => {
const q = filtersToQuery(DEFAULT_LIBRARY_FILTERS, "CURSOR123");
expect(q.cursor).toBe("CURSOR123");
});
});

View File

@ -0,0 +1,366 @@
/**
* Model Sharing Store visionA Cloud L
*
*
* 1. cursor + / filter /
* 2. profile
* 3. Dialogvisibility + shares
*
* `api-model-sharing.md`API `lib/api/model-sharing.ts`
*
* ## mock
* `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` `_setMockMode(true)`
* `model-sharing.mock.ts` fixtures API response
* flag UI / normalize
*/
"use client";
import { create } from "zustand";
import {
addShare as apiAddShare,
fetchLibrary as apiFetchLibrary,
fetchProfile as apiFetchProfile,
fetchShares as apiFetchShares,
removeShare as apiRemoveShare,
updateVisibility as apiUpdateVisibility,
normalizeLibraryPage,
normalizeProfile,
ModelSharingError,
type LibraryModel,
type LibraryQuery,
type LibrarySort,
type ModelProfile,
type ModelShare,
type ModelVisibility,
type SortOrder,
} from "@/lib/api/model-sharing";
import {
mockLibraryPage,
mockProfile,
MOCK_SHARES,
} from "@/lib/api/model-sharing.mock";
/* -------------------------------------------------------------------------- */
/* Mock 模式判定 */
/* -------------------------------------------------------------------------- */
function envMockMode(): boolean {
return (
typeof process !== "undefined" &&
process.env?.NEXT_PUBLIC_USE_MODEL_SHARING_MOCK === "1"
);
}
/* -------------------------------------------------------------------------- */
/* 列表篩選 / 排序狀態 */
/* -------------------------------------------------------------------------- */
/** 共享庫的可見性 filterUI 用all = 不過濾)。 */
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
/** 擁有關係 filterUI 用all = 全部可見)。 */
export type LibraryOwnedFilter = "all" | "mine" | "shared";
export interface LibraryFilters {
q: string;
targetChip: "all" | "kl520" | "kl720" | "kl630" | "kl730";
visibility: LibraryVisibilityFilter;
owned: LibraryOwnedFilter;
sort: LibrarySort;
order: SortOrder;
}
export const DEFAULT_LIBRARY_FILTERS: LibraryFilters = {
q: "",
targetChip: "all",
visibility: "all",
owned: "all",
sort: "created_at",
order: "desc",
};
/** 每頁筆數(對齊設計規格 §4.6desktop 3 欄 × 8 列)。 */
export const LIBRARY_PAGE_SIZE = 24;
/** 把 UI filters 轉成 API query省略 all / 空值)。 */
export function filtersToQuery(
filters: LibraryFilters,
cursor?: string,
): LibraryQuery {
const query: LibraryQuery = {
limit: LIBRARY_PAGE_SIZE,
sort: filters.sort,
order: filters.order,
};
if (cursor) query.cursor = cursor;
if (filters.q.trim()) query.q = filters.q.trim();
if (filters.targetChip !== "all") query.targetChip = filters.targetChip;
if (filters.visibility !== "all") query.visibility = filters.visibility;
if (filters.owned === "mine") query.owned = true;
else if (filters.owned === "shared") query.owned = false;
return query;
}
/* -------------------------------------------------------------------------- */
/* Store 型別 */
/* -------------------------------------------------------------------------- */
/** 分享對象操作的結果(帶 i18n code 給 UI 顯示)。 */
export type ShareOpResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface ModelSharingState {
/* ── 共享庫列表 ── */
items: LibraryModel[];
filters: LibraryFilters;
cursor: string | null;
hasMore: boolean;
/** 首屏 / filter 變更後的整體載入。 */
isLoading: boolean;
/** 「載入更多」cursor 續載)中。 */
isLoadingMore: boolean;
/** 列表載入錯誤i18n codenull = 無錯誤。 */
listError: string | null;
/* ── profile ── */
profile: ModelProfile | null;
isProfileLoading: boolean;
/** profile 錯誤 code如 not_found → 無權限 / 找不到)。 */
profileError: string | null;
/* ── 公開設定shares ── */
shares: ModelShare[];
isSharesLoading: boolean;
/* ── actions ── */
/** 設定 filters會重置分頁並重新載入首頁。 */
setFilters: (patch: Partial<LibraryFilters>) => void;
/** 載入首頁reset 已載入項 + cursor。 */
loadFirstPage: () => Promise<void>;
/** cursor 續載下一頁append。 */
loadMore: () => Promise<void>;
/** 載入 profile。 */
loadProfile: (id: string) => Promise<void>;
clearProfile: () => void;
/** 載入授權清單。 */
loadShares: (id: string) => Promise<void>;
/** 更新可見性。 */
updateVisibility: (
id: string,
visibility: ModelVisibility,
) => Promise<ShareOpResult>;
/** 新增授權對象。 */
addShare: (id: string, email: string) => Promise<ShareOpResult>;
/** 移除授權對象。 */
removeShare: (id: string, userId: string) => Promise<ShareOpResult>;
/* ── 測試 / mock ── */
_mockMode: boolean;
_setMockMode: (on: boolean) => void;
_setItems: (items: LibraryModel[]) => void;
_setProfile: (p: ModelProfile | null) => void;
_setShares: (s: ModelShare[]) => void;
}
/* -------------------------------------------------------------------------- */
/* Mock 分頁 / profile / shares走 fixtures */
/* -------------------------------------------------------------------------- */
function mockFetchLibrary(query: LibraryQuery) {
const raw = mockLibraryPage({
cursor: query.cursor,
limit: query.limit,
q: query.q,
targetChip: query.targetChip,
source: query.source,
visibility: query.visibility,
owned: query.owned,
sort: query.sort,
order: query.order,
});
return normalizeLibraryPage(raw);
}
function mockFetchProfile(id: string): ModelProfile {
const raw = mockProfile(id);
if (!raw) {
throw new ModelSharingError(404, "not_found", "model not found");
}
return normalizeProfile(raw);
}
function mockFetchShares(id: string): ModelShare[] {
const list = MOCK_SHARES[id] ?? [];
return list.map((s) => ({
userId: s.user_id,
email: s.email,
role: s.role === "editor" ? "editor" : "viewer",
createdAt: s.created_at,
}));
}
/* -------------------------------------------------------------------------- */
/* Store */
/* -------------------------------------------------------------------------- */
export const useModelSharingStore = create<ModelSharingState>()((set, get) => ({
items: [],
filters: { ...DEFAULT_LIBRARY_FILTERS },
cursor: null,
hasMore: false,
isLoading: false,
isLoadingMore: false,
listError: null,
profile: null,
isProfileLoading: false,
profileError: null,
shares: [],
isSharesLoading: false,
_mockMode: envMockMode(),
setFilters: (patch) => {
set((state) => ({ filters: { ...state.filters, ...patch } }));
void get().loadFirstPage();
},
loadFirstPage: async () => {
const { filters, _mockMode } = get();
set({ isLoading: true, listError: null, items: [], cursor: null, hasMore: false });
try {
const query = filtersToQuery(filters);
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
set({
items: page.items,
cursor: page.nextCursor,
hasMore: page.hasMore,
isLoading: false,
});
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isLoading: false, listError: code });
}
},
loadMore: async () => {
const { filters, cursor, hasMore, isLoadingMore, isLoading, _mockMode } = get();
// 防呆:無下一頁 / 正在載入時不重複觸發(無限捲動 observer 可能連續觸發)。
if (!hasMore || !cursor || isLoadingMore || isLoading) return;
set({ isLoadingMore: true, listError: null });
try {
const query = filtersToQuery(filters, cursor);
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
set((state) => ({
items: [...state.items, ...page.items],
cursor: page.nextCursor,
hasMore: page.hasMore,
isLoadingMore: false,
}));
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isLoadingMore: false, listError: code });
}
},
loadProfile: async (id) => {
const { _mockMode } = get();
set({ isProfileLoading: true, profileError: null, profile: null });
try {
const profile = _mockMode ? mockFetchProfile(id) : await apiFetchProfile(id);
set({ profile, isProfileLoading: false });
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isProfileLoading: false, profileError: code });
}
},
clearProfile: () => set({ profile: null, profileError: null }),
loadShares: async (id) => {
const { _mockMode } = get();
set({ isSharesLoading: true });
try {
const shares = _mockMode ? mockFetchShares(id) : await apiFetchShares(id);
set({ shares, isSharesLoading: false });
} catch {
// 載入授權清單失敗時清空 + 停止 loadingDialog UI 顯示空清單,操作仍可重試。
set({ shares: [], isSharesLoading: false });
}
},
updateVisibility: async (id, visibility) => {
const { _mockMode } = get();
try {
if (!_mockMode) {
await apiUpdateVisibility(id, visibility);
}
// 樂觀更新 profile 與列表中對應項的 visibility。
set((state) => ({
profile:
state.profile?.id === id
? { ...state.profile, visibility }
: state.profile,
items: state.items.map((m) =>
m.id === id ? { ...m, visibility } : m,
),
}));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
addShare: async (id, email) => {
const { _mockMode } = get();
try {
let newShare: ModelShare;
if (_mockMode) {
newShare = {
userId: `u-${email}`,
email,
role: "viewer",
createdAt: new Date().toISOString(),
};
} else {
newShare = await apiAddShare(id, email);
}
set((state) => ({ shares: [...state.shares, newShare] }));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
removeShare: async (id, userId) => {
const { _mockMode } = get();
try {
if (!_mockMode) {
await apiRemoveShare(id, userId);
}
set((state) => ({
shares: state.shares.filter((s) => s.userId !== userId),
}));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
_setMockMode: (on) => set({ _mockMode: on }),
_setItems: (items) => set({ items }),
_setProfile: (profile) => set({ profile }),
_setShares: (shares) => set({ shares }),
}));

View File

@ -36,6 +36,8 @@ export type KnownErrorCode =
| "FORBIDDEN"
| "NOT_FOUND"
| "VALIDATION_FAILED"
| "REPRESENTATIVE_DEVICE" // 409representative device 不可 register/unregisterapi-device-mgmt.md §3backend 實際回碼)
| "ALREADY_REGISTERED" // 409register 時裝置已註冊api-device-mgmt.md §3本功能新增
| "TUNNEL_DISCONNECTED"
| "TUNNEL_ERROR"
| "NOT_IMPLEMENTED"