Administrator
发布于 2026-03-04 / 2 阅读
0
0

企业级 ArtifactStore

「企业级 ArtifactStore(任务化/热插拔/可观测/可恢复)」完整代码集合:包含我们上面讨论到的全部能力

  • ✅ ArtifactStoreTool 协议(可替换/可注入)

  • ✅ FileArtifactStoreAdvanced 文件实现(落盘)

  • Booting 语义(boot record,支持任务恢复)

  • TTL 过期清理

  • 去重(hash dedupe)

  • Index(SQLite)(可查询、可迁移、可扩展)

  • refcount(payload 引用计数,删除/过期都能安全回收 payload)

  • gzip(标准 gzip header+footer)

  • AES-GCM(CryptoKit)(combined)

  • GC 兜底(清理 index 异常/历史遗留)

  • metrics(save/load/cleanup/gc 的耗时/字节数/删除数/去重命中)

  • Key 规范(TaskId 目录 + StepId Key)

你只需要把下面这些文件照着放进项目即可(例如 AIChats/Sources/AIChats/Infra/ArtifactStore/)。


0) 目录结构

Infra/ArtifactStore/
├── ArtifactModels.swift
├── ArtifactHash.swift
├── ArtifactKey.swift
├── ArtifactMetrics.swift
├── ArtifactStoreTool.swift
├── ArtifactCompression.swift
├── ArtifactCrypto.swift
├── KeychainKeyProvider.swift
├── ArtifactIndexSQLite.swift
└── FileArtifactStoreAdvanced.swift


1) ArtifactModels.swift

import Foundation

public enum ArtifactKind: String, Codable, Sendable {
  case result
  case eventLog
  case snapshot
  case toolOutput
  case debug
}

public struct ArtifactPolicy: Sendable {
  public var ttl: TimeInterval?
  public var enableDedupe: Bool
  public var schemaVersion: Int
  public var tags: [String: String]

  public init(
    ttl: TimeInterval? = nil,
    enableDedupe: Bool = true,
    schemaVersion: Int = 1,
    tags: [String: String] = [:]
  ) {
    self.ttl = ttl
    self.enableDedupe = enableDedupe
    self.schemaVersion = schemaVersion
    self.tags = tags
  }
}

public struct ArtifactMeta: Codable, Sendable {
  public var id: String
  public var taskId: String
  public var kind: ArtifactKind

  public var schemaVersion: Int
  public var createdAt: Date
  public var expiresAt: Date?

  public var payloadHash: String
  public var payloadRelativePath: String
  public var metaRelativePath: String

  public var tags: [String: String]
}

public struct SavedArtifact: Sendable {
  public var meta: ArtifactMeta
  public var metaURL: URL
  public var payloadURL: URL
  public var deduped: Bool
}

public struct BootRecord: Codable, Sendable {
  public enum Status: String, Codable, Sendable {
    case booting
    case finished
    case failed
    case cancelled
  }

  public var taskId: String
  public var taskType: String
  public var startedAt: Date
  public var updatedAt: Date
  public var status: Status
  public var note: String?
  public var tags: [String: String]
}


2) ArtifactHash.swift

import Foundation
import CryptoKit

public enum ArtifactHash {
  public static func sha256Hex(_ data: Data) -> String {
    let digest = SHA256.hash(data: data)
    return digest.map { String(format: "%02x", $0) }.joined()
  }
}

public enum PathSanitizer {
  public static func safePathComponent(_ raw: String) -> String {
    let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
    return raw.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
      .reduce(into: "") { $0.append($1) }
  }
}


3) ArtifactKey.swift(Key 规范:包含 StepId)

import Foundation

public struct ArtifactKey: Sendable, Hashable, CustomStringConvertible {
  public let stepId: String
  public let name: String
  public let domain: String?

  public init(stepId: String, name: String, domain: String? = nil) {
    self.stepId = stepId
    self.name = name
    self.domain = domain
  }

  public var normalized: String {
    let safeStep = PathSanitizer.safePathComponent(stepId)
    let safeName = PathSanitizer.safePathComponent(name)
    if let domain, !domain.isEmpty {
      let safeDomain = PathSanitizer.safePathComponent(domain)
      return "\(safeDomain).step.\(safeStep).\(safeName)"
    } else {
      return "step.\(safeStep).\(safeName)"
    }
  }

  public var description: String { normalized }
}

public enum ArtifactKeyFactory {
  public static func numeric(step: Int, width: Int = 4, name: String, domain: String? = nil) -> ArtifactKey {
    let stepId = String(format: "%0\(width)d", step)
    return ArtifactKey(stepId: stepId, name: name, domain: domain)
  }

  public static func tool(toolId: String, index: Int, name: String) -> ArtifactKey {
    ArtifactKey(stepId: "\(toolId).\(index)", name: name, domain: "tool")
  }

  public static func system(name: String) -> ArtifactKey {
    ArtifactKey(stepId: "system", name: name, domain: "system")
  }
}


4) ArtifactMetrics.swift(可观测)

import Foundation

public enum ArtifactOperation: String, Sendable {
  case save
  case load
  case cleanupExpired
  case garbageCollect
}

public struct ArtifactMetrics: Sendable {
  public let op: ArtifactOperation
  public let taskId: String?
  public let kind: ArtifactKind?
  public let key: String?

  public let startedAt: Date
  public let durationMs: Double

  public let bytesWritten: Int
  public let bytesRead: Int

  public let dedupeHit: Bool
  public let filesDeleted: Int

  public init(
    op: ArtifactOperation,
    taskId: String? = nil,
    kind: ArtifactKind? = nil,
    key: String? = nil,
    startedAt: Date,
    durationMs: Double,
    bytesWritten: Int = 0,
    bytesRead: Int = 0,
    dedupeHit: Bool = false,
    filesDeleted: Int = 0
  ) {
    self.op = op
    self.taskId = taskId
    self.kind = kind
    self.key = key
    self.startedAt = startedAt
    self.durationMs = durationMs
    self.bytesWritten = bytesWritten
    self.bytesRead = bytesRead
    self.dedupeHit = dedupeHit
    self.filesDeleted = filesDeleted
  }
}

public protocol ArtifactMetricsSink: Sendable {
  func emit(_ m: ArtifactMetrics)
}

public struct ConsoleMetricsSink: ArtifactMetricsSink {
  public init() {}
  public func emit(_ m: ArtifactMetrics) {
    print("[ArtifactMetrics] op=\(m.op.rawValue) ms=\(String(format:"%.2f", m.durationMs)) " +
          "task=\(m.taskId ?? "-") kind=\(m.kind?.rawValue ?? "-") key=\(m.key ?? "-") " +
          "w=\(m.bytesWritten) r=\(m.bytesRead) dedupe=\(m.dedupeHit) deleted=\(m.filesDeleted)")
  }
}

public enum PerfClock {
  public static func now() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
  public static func ms(since start: UInt64) -> Double {
    let end = DispatchTime.now().uptimeNanoseconds
    return Double(end - start) / 1_000_000.0
  }
}


5) ArtifactStoreTool.swift(协议)

import Foundation

public protocol ArtifactStoreTool: Sendable {
  // Booting lifecycle
  func bootStart(taskId: String, taskType: String, tags: [String: String]) async throws
  func bootFinish(taskId: String, status: BootRecord.Status, note: String?) async throws
  func listBootingTasks() async throws -> [BootRecord]

  // Artifact CRUD
  func saveCodableArtifact<T: Codable>(
    _ payload: T,
    taskId: String,
    kind: ArtifactKind,
    key: String,
    policy: ArtifactPolicy
  ) async throws -> SavedArtifact

  func loadCodableArtifact<T: Codable>(
    _ type: T.Type,
    taskId: String,
    kind: ArtifactKind,
    key: String
  ) async throws -> T

  func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMeta
  func deleteArtifact(taskId: String, kind: ArtifactKind, key: String) async throws

  // Maintenance
  func cleanupExpiredArtifacts() async throws
  func garbageCollectPayloads() async throws
  func storeRootURL() async -> URL
}


6) ArtifactCompression.swift(标准 gzip)

import Foundation
import Compression

public enum ArtifactCompression {

  public enum CompressionError: Error {
    case failed
    case invalidGzip
  }

  public static func gzipCompress(_ data: Data) throws -> Data {
    let deflated = try deflate(data)
    let crc = crc32(data)
    let isize = UInt32(data.count & 0xFFFF_FFFF)

    var out = Data()
    out.reserveCapacity(10 + deflated.count + 8)

    out.append(contentsOf: [
      0x1f, 0x8b, // ID1, ID2
      0x08,       // CM = deflate
      0x00,       // FLG
      0x00, 0x00, 0x00, 0x00, // MTIME
      0x00,       // XFL
      0xff        // OS
    ])

    out.append(deflated)
    out.append(contentsOf: withUnsafeBytes(of: crc.littleEndian, Array.init))
    out.append(contentsOf: withUnsafeBytes(of: isize.littleEndian, Array.init))
    return out
  }

  public static func gzipDecompress(_ gzip: Data) throws -> Data {
    guard gzip.count >= 18 else { throw CompressionError.invalidGzip }
    guard gzip[0] == 0x1f, gzip[1] == 0x8b, gzip[2] == 0x08 else {
      throw CompressionError.invalidGzip
    }
    let flg = gzip[3]
    guard flg == 0 else { throw CompressionError.invalidGzip }

    let deflateStart = 10
    let deflateEnd = gzip.count - 8
    guard deflateEnd > deflateStart else { throw CompressionError.invalidGzip }

    let deflated = gzip.subdata(in: deflateStart..<deflateEnd)
    let inflated = try inflate(deflated)

    let crcStored = UInt32(littleEndian: gzip.withUnsafeBytes { ptr in
      ptr.load(fromByteOffset: gzip.count - 8, as: UInt32.self)
    })
    let isizeStored = UInt32(littleEndian: gzip.withUnsafeBytes { ptr in
      ptr.load(fromByteOffset: gzip.count - 4, as: UInt32.self)
    })

    let crcCalc = crc32(inflated)
    let isizeCalc = UInt32(inflated.count & 0xFFFF_FFFF)

    guard crcStored == crcCalc, isizeStored == isizeCalc else {
      throw CompressionError.invalidGzip
    }
    return inflated
  }

  private static func deflate(_ data: Data) throws -> Data {
    try process(data, operation: COMPRESSION_STREAM_ENCODE)
  }

  private static func inflate(_ data: Data) throws -> Data {
    try process(data, operation: COMPRESSION_STREAM_DECODE)
  }

  private static func process(_ data: Data, operation: compression_stream_operation) throws -> Data {
    var stream = compression_stream()
    var status = compression_stream_init(&stream, operation, COMPRESSION_ZLIB)
    guard status != COMPRESSION_STATUS_ERROR else { throw CompressionError.failed }
    defer { compression_stream_destroy(&stream) }

    let dstBufferSize = 64 * 1024
    let dstBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: dstBufferSize)
    defer { dstBuffer.deallocate() }

    var output = Data()

    data.withUnsafeBytes { srcPtr in
      stream.src_ptr = srcPtr.bindMemory(to: UInt8.self).baseAddress!
      stream.src_size = data.count

      stream.dst_ptr = dstBuffer
      stream.dst_size = dstBufferSize

      while true {
        status = compression_stream_process(&stream, 0)

        switch status {
        case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
          let produced = dstBufferSize - stream.dst_size
          if produced > 0 {
            output.append(dstBuffer, count: produced)
          }
          stream.dst_ptr = dstBuffer
          stream.dst_size = dstBufferSize
          if status == COMPRESSION_STATUS_END { return }
        default:
          return
        }
      }
    }

    if status == COMPRESSION_STATUS_ERROR { throw CompressionError.failed }
    return output
  }

  private static func crc32(_ data: Data) -> UInt32 {
    var crc: UInt32 = 0xFFFF_FFFF
    for b in data {
      crc ^= UInt32(b)
      for _ in 0..<8 {
        let mask = (crc & 1) != 0 ? 0xEDB8_8320 : 0
        crc = (crc >> 1) ^ UInt32(mask)
      }
    }
    return ~crc
  }
}


7) ArtifactCrypto.swift(AES-GCM + KeyProvider)

import Foundation
import CryptoKit

public protocol ArtifactKeyProvider: Sendable {
  func symmetricKey() throws -> SymmetricKey
}

public struct InMemoryKeyProvider: ArtifactKeyProvider {
  private let key: SymmetricKey
  public init(seed: Data) {
    self.key = SymmetricKey(data: seed)
  }
  public func symmetricKey() throws -> SymmetricKey { key }
}

public enum ArtifactCrypto {
  public enum CryptoError: Error { case failed }

  public static func encrypt(_ plaintext: Data, key: SymmetricKey) throws -> Data {
    let sealed = try AES.GCM.seal(plaintext, using: key)
    guard let combined = sealed.combined else { throw CryptoError.failed }
    return combined
  }

  public static func decrypt(_ combined: Data, key: SymmetricKey) throws -> Data {
    let box = try AES.GCM.SealedBox(combined: combined)
    return try AES.GCM.open(box, using: key)
  }
}


8) KeychainKeyProvider.swift(生产级密钥)

import Foundation
import CryptoKit
import Security

public struct KeychainKeyProvider: ArtifactKeyProvider {

  public enum KeychainError: Error {
    case unexpectedStatus(OSStatus)
    case invalidKeyData
  }

  private let service: String
  private let account: String

  public init(service: String = "AIChats.ArtifactStore", account: String = "aesgcm.symmetricKey") {
    self.service = service
    self.account = account
  }

  public func symmetricKey() throws -> SymmetricKey {
    if let data = try loadKeyData() {
      guard data.count == 32 else { throw KeychainError.invalidKeyData }
      return SymmetricKey(data: data)
    }

    var bytes = [UInt8](repeating: 0, count: 32)
    let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
    guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }

    let data = Data(bytes)
    try saveKeyData(data)
    return SymmetricKey(data: data)
  }

  private func loadKeyData() throws -> Data? {
    let query: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrService as String: service,
      kSecAttrAccount as String: account,
      kSecReturnData as String: true,
      kSecMatchLimit as String: kSecMatchLimitOne
    ]

    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)

    if status == errSecItemNotFound { return nil }
    guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }

    guard let data = item as? Data else { throw KeychainError.invalidKeyData }
    return data
  }

  private func saveKeyData(_ data: Data) throws {
    let add: [String: Any] = [
      kSecClass as String: kSecClassGenericPassword,
      kSecAttrService as String: service,
      kSecAttrAccount as String: account,
      kSecValueData as String: data,
      kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
    ]

    let addStatus = SecItemAdd(add as CFDictionary, nil)
    if addStatus == errSecSuccess { return }

    if addStatus == errSecDuplicateItem {
      let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecAttrAccount as String: account
      ]
      let update: [String: Any] = [kSecValueData as String: data]
      let upStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
      guard upStatus == errSecSuccess else { throw KeychainError.unexpectedStatus(upStatus) }
      return
    }

    throw KeychainError.unexpectedStatus(addStatus)
  }
}


9) ArtifactIndexSQLite.swift(Index + refcount + 查询 + 事务)

import Foundation
import SQLite3

public actor ArtifactIndexSQLite {

  public enum IndexError: Error {
    case openFailed
    case execFailed(String)
    case bindFailed
    case stepFailed
    case notFound
  }

  private var db: OpaquePointer?

  public init(indexURL: URL) throws {
    try open(at: indexURL)
    try migrate()
  }

  deinit {
    if let db { sqlite3_close(db) }
  }

  private func migrate() throws {
    try exec("""
    PRAGMA journal_mode=WAL;
    PRAGMA synchronous=NORMAL;
    """)

    try exec("""
    CREATE TABLE IF NOT EXISTS payloads (
      hash TEXT PRIMARY KEY,
      path TEXT NOT NULL,
      size INTEGER NOT NULL,
      refCount INTEGER NOT NULL,
      createdAt REAL NOT NULL,
      lastAccessAt REAL
    );
    """)

    try exec("""
    CREATE TABLE IF NOT EXISTS artifacts (
      taskId TEXT NOT NULL,
      kind TEXT NOT NULL,
      key TEXT NOT NULL,
      payloadHash TEXT NOT NULL,
      metaPath TEXT NOT NULL,
      createdAt REAL NOT NULL,
      expiresAt REAL,
      PRIMARY KEY(taskId, kind, key),
      FOREIGN KEY(payloadHash) REFERENCES payloads(hash)
    );
    """)

    try exec("CREATE INDEX IF NOT EXISTS idx_artifacts_expiresAt ON artifacts(expiresAt);")
    try exec("CREATE INDEX IF NOT EXISTS idx_artifacts_payloadHash ON artifacts(payloadHash);")
  }

  // MARK: Transactions
  public func beginTransaction() throws { try exec("BEGIN IMMEDIATE TRANSACTION;") }
  public func commit() throws { try exec("COMMIT;") }
  public func rollback() throws { try exec("ROLLBACK;") }

  // MARK: Payload + refcount

  public func ensurePayload(hash: String, path: String, size: Int) throws {
    var stmt: OpaquePointer?
    try prepare("""
      INSERT INTO payloads(hash, path, size, refCount, createdAt)
      VALUES(?, ?, ?, 0, ?)
      ON CONFLICT(hash) DO NOTHING;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }

    try bindText(stmt, 1, hash)
    try bindText(stmt, 2, path)
    sqlite3_bind_int64(stmt, 3, sqlite3_int64(size))
    sqlite3_bind_double(stmt, 4, Date().timeIntervalSince1970)
    try stepDone(stmt)
  }

  public func incrementRef(hash: String) throws {
    try exec("UPDATE payloads SET refCount = refCount + 1, lastAccessAt=\(Date().timeIntervalSince1970) WHERE hash='\(escape(hash))';")
  }

  public func decrementRef(hash: String) throws -> Int {
    try exec("UPDATE payloads SET refCount = refCount - 1, lastAccessAt=\(Date().timeIntervalSince1970) WHERE hash='\(escape(hash))';")
    return try payloadRefCount(hash: hash)
  }

  public func payloadPath(hash: String) throws -> String {
    var stmt: OpaquePointer?
    try prepare("SELECT path FROM payloads WHERE hash=? LIMIT 1;", &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, hash)

    guard sqlite3_step(stmt) == SQLITE_ROW else { throw IndexError.notFound }
    guard let cstr = sqlite3_column_text(stmt, 0) else { throw IndexError.notFound }
    return String(cString: cstr)
  }

  public func payloadRefCount(hash: String) throws -> Int {
    var stmt: OpaquePointer?
    try prepare("SELECT refCount FROM payloads WHERE hash=? LIMIT 1;", &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, hash)

    guard sqlite3_step(stmt) == SQLITE_ROW else { throw IndexError.notFound }
    return Int(sqlite3_column_int64(stmt, 0))
  }

  public func deletePayloadRow(hash: String) throws {
    try exec("DELETE FROM payloads WHERE hash='\(escape(hash))';")
  }

  // MARK: Artifacts

  public func getArtifactPayloadHash(taskId: String, kind: String, key: String) throws -> String? {
    var stmt: OpaquePointer?
    try prepare("SELECT payloadHash FROM artifacts WHERE taskId=? AND kind=? AND key=? LIMIT 1;", &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, taskId)
    try bindText(stmt, 2, kind)
    try bindText(stmt, 3, key)

    let rc = sqlite3_step(stmt)
    if rc == SQLITE_ROW {
      guard let cstr = sqlite3_column_text(stmt, 0) else { return nil }
      return String(cString: cstr)
    }
    return nil
  }

  public func upsertArtifact(
    taskId: String,
    kind: String,
    key: String,
    payloadHash: String,
    metaPath: String,
    createdAt: Date,
    expiresAt: Date?
  ) throws -> String? {

    let oldHash = try getArtifactPayloadHash(taskId: taskId, kind: kind, key: key)

    var stmt: OpaquePointer?
    try prepare("""
      INSERT INTO artifacts(taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt)
      VALUES(?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(taskId, kind, key) DO UPDATE SET
        payloadHash=excluded.payloadHash,
        metaPath=excluded.metaPath,
        createdAt=excluded.createdAt,
        expiresAt=excluded.expiresAt;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }

    try bindText(stmt, 1, taskId)
    try bindText(stmt, 2, kind)
    try bindText(stmt, 3, key)
    try bindText(stmt, 4, payloadHash)
    try bindText(stmt, 5, metaPath)
    sqlite3_bind_double(stmt, 6, createdAt.timeIntervalSince1970)

    if let expiresAt {
      sqlite3_bind_double(stmt, 7, expiresAt.timeIntervalSince1970)
    } else {
      sqlite3_bind_null(stmt, 7)
    }

    try stepDone(stmt)
    return oldHash
  }

  public func deleteArtifact(taskId: String, kind: String, key: String) throws -> String? {
    let oldHash = try getArtifactPayloadHash(taskId: taskId, kind: kind, key: key)
    guard oldHash != nil else { return nil }

    var stmt: OpaquePointer?
    try prepare("DELETE FROM artifacts WHERE taskId=? AND kind=? AND key=?;", &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, taskId)
    try bindText(stmt, 2, kind)
    try bindText(stmt, 3, key)
    try stepDone(stmt)
    return oldHash
  }

  public func expiredArtifacts(now: Date) throws -> [(String, String, String, String, String)] {
    var stmt: OpaquePointer?
    try prepare("""
      SELECT taskId, kind, key, payloadHash, metaPath
      FROM artifacts
      WHERE expiresAt IS NOT NULL AND expiresAt <= ?
      LIMIT 500;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }
    sqlite3_bind_double(stmt, 1, now.timeIntervalSince1970)

    var result: [(String, String, String, String, String)] = []
    while sqlite3_step(stmt) == SQLITE_ROW {
      func col(_ i: Int) -> String { String(cString: sqlite3_column_text(stmt, i)) }
      result.append((col(0), col(1), col(2), col(3), col(4)))
    }
    return result
  }

  // MARK: Query API

  public struct ArtifactRow: Sendable {
    public let taskId: String
    public let kind: String
    public let key: String
    public let payloadHash: String
    public let metaPath: String
    public let createdAt: Date
    public let expiresAt: Date?
  }

  public func listArtifacts(taskId: String) throws -> [ArtifactRow] {
    var stmt: OpaquePointer?
    try prepare("""
      SELECT taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt
      FROM artifacts
      WHERE taskId=?
      ORDER BY createdAt DESC;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, taskId)

    var rows: [ArtifactRow] = []
    while sqlite3_step(stmt) == SQLITE_ROW {
      rows.append(readArtifactRow(stmt))
    }
    return rows
  }

  public func latestArtifacts(kind: String, limit: Int) throws -> [ArtifactRow] {
    var stmt: OpaquePointer?
    try prepare("""
      SELECT taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt
      FROM artifacts
      WHERE kind=?
      ORDER BY createdAt DESC
      LIMIT ?;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, kind)
    sqlite3_bind_int64(stmt, 2, sqlite3_int64(max(1, limit)))

    var rows: [ArtifactRow] = []
    while sqlite3_step(stmt) == SQLITE_ROW {
      rows.append(readArtifactRow(stmt))
    }
    return rows
  }

  public func listTasks(kind: String, limit: Int) throws -> [String] {
    // 简化:按 artifacts 中出现的 taskId 去重并限制数量
    var stmt: OpaquePointer?
    try prepare("""
      SELECT DISTINCT taskId
      FROM artifacts
      WHERE kind=?
      ORDER BY taskId DESC
      LIMIT ?;
    """, &stmt)
    defer { sqlite3_finalize(stmt) }
    try bindText(stmt, 1, kind)
    sqlite3_bind_int64(stmt, 2, sqlite3_int64(max(1, limit)))

    var ids: [String] = []
    while sqlite3_step(stmt) == SQLITE_ROW {
      if let c = sqlite3_column_text(stmt, 0) {
        ids.append(String(cString: c))
      }
    }
    return ids
  }

  // MARK: Internals

  private func open(at url: URL) throws {
    if sqlite3_open(url.path, &db) != SQLITE_OK {
      throw IndexError.openFailed
    }
  }

  private func exec(_ sql: String) throws {
    var err: UnsafeMutablePointer<Int8>?
    if sqlite3_exec(db, sql, nil, nil, &err) != SQLITE_OK {
      let msg = err.map { String(cString: $0) } ?? "unknown"
      sqlite3_free(err)
      throw IndexError.execFailed(msg)
    }
  }

  private func prepare(_ sql: String, _ stmt: inout OpaquePointer?) throws {
    if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK {
      throw IndexError.execFailed("prepare failed: \(sql)")
    }
  }

  private func bindText(_ stmt: OpaquePointer?, _ idx: Int32, _ text: String) throws {
    if sqlite3_bind_text(stmt, idx, text, -1, SQLITE_TRANSIENT) != SQLITE_OK {
      throw IndexError.bindFailed
    }
  }

  private func stepDone(_ stmt: OpaquePointer?) throws {
    if sqlite3_step(stmt) != SQLITE_DONE {
      throw IndexError.stepFailed
    }
  }

  private func escape(_ s: String) -> String {
    s.replacingOccurrences(of: "'", with: "''")
  }

  private func readArtifactRow(_ stmt: OpaquePointer?) -> ArtifactRow {
    func text(_ i: Int32) -> String { String(cString: sqlite3_column_text(stmt, i)) }
    func date(_ i: Int32) -> Date { Date(timeIntervalSince1970: sqlite3_column_double(stmt, i)) }

    let taskId = text(0)
    let kind = text(1)
    let key = text(2)
    let payloadHash = text(3)
    let metaPath = text(4)
    let createdAt = date(5)

    let expiresAt: Date?
    if sqlite3_column_type(stmt, 6) == SQLITE_NULL {
      expiresAt = nil
    } else {
      expiresAt = date(6)
    }

    return ArtifactRow(
      taskId: taskId,
      kind: kind,
      key: key,
      payloadHash: payloadHash,
      metaPath: metaPath,
      createdAt: createdAt,
      expiresAt: expiresAt
    )
  }
}


10) FileArtifactStoreAdvanced.swift(总实现:booting/TTL/dedupe/index/refcount/gzip/aesgcm/GC/metrics/Key)

import Foundation
import CryptoKit

public struct PayloadEncoding: Codable, Sendable {
  public var isGzipped: Bool
  public var isEncrypted: Bool
  public init(isGzipped: Bool, isEncrypted: Bool) {
    self.isGzipped = isGzipped
    self.isEncrypted = isEncrypted
  }
}

/// 为了兼容你已有 ArtifactMeta,不强制改原结构:
/// meta 文件里存 { base: ArtifactMeta, encoding: PayloadEncoding }
public struct ArtifactMetaV2: Codable, Sendable {
  public var base: ArtifactMeta
  public var encoding: PayloadEncoding
}

public actor FileArtifactStoreAdvanced: ArtifactStoreTool {

  public enum StoreError: Error {
    case notFound
    case bootRecordMissing
  }

  private let fm: FileManager
  private let root: URL
  private let bootsDir: URL
  private let payloadsDir: URL
  private let tasksDir: URL

  private let encoder: JSONEncoder
  private let decoder: JSONDecoder

  private let metrics: ArtifactMetricsSink
  private let keyProvider: ArtifactKeyProvider
  private let index: ArtifactIndexSQLite

  private let compressThresholdBytes: Int
  private let defaultEncrypt: Bool
  private let defaultCompress: Bool

  public init(
    folderName: String = "AIChatsArtifacts",
    fm: FileManager = .default,
    metrics: ArtifactMetricsSink = ConsoleMetricsSink(),
    keyProvider: ArtifactKeyProvider = KeychainKeyProvider(),
    indexFileName: String = "artifact_index.sqlite",
    compressThresholdBytes: Int = 8 * 1024,
    defaultEncrypt: Bool = true,
    defaultCompress: Bool = true
  ) throws {

    self.fm = fm
    self.metrics = metrics
    self.keyProvider = keyProvider
    self.compressThresholdBytes = compressThresholdBytes
    self.defaultEncrypt = defaultEncrypt
    self.defaultCompress = defaultCompress

    self.encoder = {
      let e = JSONEncoder()
      e.outputFormatting = [.prettyPrinted, .sortedKeys]
      e.dateEncodingStrategy = .iso8601
      return e
    }()

    self.decoder = {
      let d = JSONDecoder()
      d.dateDecodingStrategy = .iso8601
      return d
    }()

    let support = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
    self.root = support.appendingPathComponent(folderName, isDirectory: true)
    self.bootsDir = root.appendingPathComponent("boots", isDirectory: true)
    self.payloadsDir = root.appendingPathComponent("payloads", isDirectory: true)
    self.tasksDir = root.appendingPathComponent("tasks", isDirectory: true)

    try fm.createDirectory(at: root, withIntermediateDirectories: true)
    try fm.createDirectory(at: bootsDir, withIntermediateDirectories: true)
    try fm.createDirectory(at: payloadsDir, withIntermediateDirectories: true)
    try fm.createDirectory(at: tasksDir, withIntermediateDirectories: true)

    let indexURL = root.appendingPathComponent(indexFileName, isDirectory: false)
    self.index = try ArtifactIndexSQLite(indexURL: indexURL)
  }

  public func storeRootURL() async -> URL { root }

  // MARK: - Booting

  public func bootStart(taskId: String, taskType: String, tags: [String : String]) async throws {
    let tid = PathSanitizer.safePathComponent(taskId)
    let url = bootsDir.appendingPathComponent("\(tid).json", isDirectory: false)

    let now = Date()
    let record = BootRecord(
      taskId: taskId,
      taskType: taskType,
      startedAt: now,
      updatedAt: now,
      status: .booting,
      note: nil,
      tags: tags
    )

    let data = try encoder.encode(record)
    try data.write(to: url, options: [.atomic])
  }

  public func bootFinish(taskId: String, status: BootRecord.Status, note: String?) async throws {
    let tid = PathSanitizer.safePathComponent(taskId)
    let url = bootsDir.appendingPathComponent("\(tid).json", isDirectory: false)
    guard fm.fileExists(atPath: url.path) else { throw StoreError.bootRecordMissing }

    let data = try Data(contentsOf: url)
    var record = try decoder.decode(BootRecord.self, from: data)
    record.status = status
    record.updatedAt = Date()
    record.note = note

    let out = try encoder.encode(record)
    try out.write(to: url, options: [.atomic])
  }

  public func listBootingTasks() async throws -> [BootRecord] {
    let urls = (try? fm.contentsOfDirectory(at: bootsDir, includingPropertiesForKeys: nil)) ?? []
    var records: [BootRecord] = []
    for u in urls where u.pathExtension.lowercased() == "json" {
      if let data = try? Data(contentsOf: u),
         let r = try? decoder.decode(BootRecord.self, from: data),
         r.status == .booting {
        records.append(r)
      }
    }
    return records.sorted(by: { $0.startedAt > $1.startedAt })
  }

  // MARK: - Key overloads (推荐)

  public func saveCodableArtifact<T: Codable>(
    _ payload: T,
    taskId: String,
    kind: ArtifactKind,
    key: ArtifactKey,
    policy: ArtifactPolicy
  ) async throws -> SavedArtifact {
    try await saveCodableArtifact(payload, taskId: taskId, kind: kind, key: key.normalized, policy: policy)
  }

  public func loadCodableArtifact<T: Codable>(
    _ type: T.Type,
    taskId: String,
    kind: ArtifactKind,
    key: ArtifactKey
  ) async throws -> T {
    try await loadCodableArtifact(type, taskId: taskId, kind: kind, key: key.normalized)
  }

  public func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: ArtifactKey) async throws -> ArtifactMeta {
    try await loadArtifactMeta(taskId: taskId, kind: kind, key: key.normalized)
  }

  public func deleteArtifact(taskId: String, kind: ArtifactKind, key: ArtifactKey) async throws {
    try await deleteArtifact(taskId: taskId, kind: kind, key: key.normalized)
  }

  // MARK: - CRUD

  public func saveCodableArtifact<T: Codable>(
    _ payload: T,
    taskId: String,
    kind: ArtifactKind,
    key: String,
    policy: ArtifactPolicy
  ) async throws -> SavedArtifact {

    let t0 = PerfClock.now()
    let startedAt = Date()

    // 1) encode JSON
    let json = try encoder.encode(payload)

    // 2) decide compress/encrypt via tags
    let shouldCompress = boolTag(policy.tags, "compress", default: defaultCompress) && json.count >= compressThresholdBytes
    let shouldEncrypt = boolTag(policy.tags, "encrypt", default: defaultEncrypt)

    // 3) pipeline: gzip -> aesgcm
    var data = json
    var encoding = PayloadEncoding(isGzipped: false, isEncrypted: false)

    if shouldCompress {
      data = try ArtifactCompression.gzipCompress(data)
      encoding.isGzipped = true
    }

    if shouldEncrypt {
      let keyObj = try keyProvider.symmetricKey()
      data = try ArtifactCrypto.encrypt(data, key: keyObj)
      encoding.isEncrypted = true
    }

    // 4) hash on final bytes (dedupe stable)
    let payloadHash = ArtifactHash.sha256Hex(data)

    // 5) paths
    let payloadURL = payloadsDir.appendingPathComponent("\(payloadHash).bin", isDirectory: false)

    let tid = PathSanitizer.safePathComponent(taskId)
    let knd = PathSanitizer.safePathComponent(kind.rawValue)
    let safeKey = PathSanitizer.safePathComponent(key)

    let taskKindDir = tasksDir
      .appendingPathComponent(tid, isDirectory: true)
      .appendingPathComponent(knd, isDirectory: true)
    try fm.createDirectory(at: taskKindDir, withIntermediateDirectories: true)

    let metaURL = taskKindDir.appendingPathComponent("\(safeKey).meta.json", isDirectory: false)

    let now = Date()
    let expiresAt: Date? = policy.ttl.map { now.addingTimeInterval($0) }
    let artifactId = "\(tid)_\(knd)_\(safeKey)_\(payloadHash.prefix(12))"

    let baseMeta = ArtifactMeta(
      id: artifactId,
      taskId: taskId,
      kind: kind,
      schemaVersion: policy.schemaVersion,
      createdAt: now,
      expiresAt: expiresAt,
      payloadHash: payloadHash,
      payloadRelativePath: relativePath(from: root, to: payloadURL),
      metaRelativePath: relativePath(from: root, to: metaURL),
      tags: policy.tags
    )

    let metaV2 = ArtifactMetaV2(base: baseMeta, encoding: encoding)

    // 6) write payload (dedupe)
    var deduped = false
    if policy.enableDedupe, fm.fileExists(atPath: payloadURL.path) {
      deduped = true
    } else {
      try data.write(to: payloadURL, options: [.atomic])
    }

    // 7) index transaction (refcount)
    var oldHash: String?
    do {
      try index.beginTransaction()

      try index.ensurePayload(hash: payloadHash, path: baseMeta.payloadRelativePath, size: data.count)
      oldHash = try index.upsertArtifact(
        taskId: taskId,
        kind: kind.rawValue,
        key: key,
        payloadHash: payloadHash,
        metaPath: baseMeta.metaRelativePath,
        createdAt: now,
        expiresAt: expiresAt
      )

      try index.incrementRef(hash: payloadHash)

      // if replacing old hash, decrement old (file deletion after commit)
      if let oldHash, oldHash != payloadHash {
        _ = try index.decrementRef(hash: oldHash)
      }

      try index.commit()
    } catch {
      try? index.rollback()
      // index failed -> remove newly written payload (best effort)
      if !deduped {
        try? fm.removeItem(at: payloadURL)
      }
      throw error
    }

    // 8) write meta (best effort, do not rollback index)
    let metaData = try encoder.encode(metaV2)
    do {
      try metaData.write(to: metaURL, options: [.atomic])
    } catch {
      // 生产里你可以在这里发告警/日志,或做维护任务修复 meta
    }

    // 9) commit之后:如果 oldHash refcount <= 0 -> recycle
    if let oldHash, oldHash != payloadHash {
      if let rc = try? index.payloadRefCount(hash: oldHash), rc <= 0 {
        try? removePayloadCompletely(hash: oldHash)
      }
    }

    metrics.emit(.init(
      op: .save,
      taskId: taskId,
      kind: kind,
      key: key,
      startedAt: startedAt,
      durationMs: PerfClock.ms(since: t0),
      bytesWritten: (deduped ? 0 : data.count) + metaData.count,
      bytesRead: 0,
      dedupeHit: deduped,
      filesDeleted: 0
    ))

    return SavedArtifact(meta: baseMeta, metaURL: metaURL, payloadURL: payloadURL, deduped: deduped)
  }

  public func loadCodableArtifact<T: Codable>(
    _ type: T.Type,
    taskId: String,
    kind: ArtifactKind,
    key: String
  ) async throws -> T {

    let t0 = PerfClock.now()
    let startedAt = Date()

    let metaV2 = try await loadArtifactMetaV2(taskId: taskId, kind: kind, key: key)
    let payloadURL = root.appendingPathComponent(metaV2.base.payloadRelativePath, isDirectory: false)
    guard fm.fileExists(atPath: payloadURL.path) else { throw StoreError.notFound }

    let payloadData = try Data(contentsOf: payloadURL)

    var data = payloadData
    if metaV2.encoding.isEncrypted {
      let keyObj = try keyProvider.symmetricKey()
      data = try ArtifactCrypto.decrypt(data, key: keyObj)
    }
    if metaV2.encoding.isGzipped {
      data = try ArtifactCompression.gzipDecompress(data)
    }

    let value = try decoder.decode(T.self, from: data)

    metrics.emit(.init(
      op: .load,
      taskId: taskId,
      kind: kind,
      key: key,
      startedAt: startedAt,
      durationMs: PerfClock.ms(since: t0),
      bytesWritten: 0,
      bytesRead: payloadData.count,
      dedupeHit: false,
      filesDeleted: 0
    ))

    return value
  }

  public func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMeta {
    let metaV2 = try await loadArtifactMetaV2(taskId: taskId, kind: kind, key: key)
    return metaV2.base
  }

  public func deleteArtifact(taskId: String, kind: ArtifactKind, key: String) async throws {
    // index delete -> returns old hash
    let oldHash = try index.deleteArtifact(taskId: taskId, kind: kind.rawValue, key: key)

    // remove meta file
    let metaURL = metaURL(taskId: taskId, kind: kind, key: key)
    if fm.fileExists(atPath: metaURL.path) {
      try? fm.removeItem(at: metaURL)
    }

    // refcount -- and maybe recycle payload
    if let oldHash {
      let rc = (try? index.decrementRef(hash: oldHash)) ?? 0
      if rc <= 0 {
        try? removePayloadCompletely(hash: oldHash)
      }
    }
  }

  // MARK: - TTL cleanup

  public func cleanupExpiredArtifacts() async throws {
    let t0 = PerfClock.now()
    let startedAt = Date()
    var deleted = 0

    let expired = try index.expiredArtifacts(now: Date())
    for (taskId, kindRaw, key, payloadHash, metaPath) in expired {
      _ = try index.deleteArtifact(taskId: taskId, kind: kindRaw, key: key)
      deleted += 1

      // remove meta
      let metaURL = root.appendingPathComponent(metaPath, isDirectory: false)
      try? fm.removeItem(at: metaURL)

      // refcount--
      let rc = (try? index.decrementRef(hash: payloadHash)) ?? 0
      if rc <= 0 {
        try? removePayloadCompletely(hash: payloadHash)
      }
    }

    metrics.emit(.init(
      op: .cleanupExpired,
      startedAt: startedAt,
      durationMs: PerfClock.ms(since: t0),
      bytesWritten: 0,
      bytesRead: 0,
      dedupeHit: false,
      filesDeleted: deleted
    ))
  }

  // MARK: - GC (fallback)

  public func garbageCollectPayloads() async throws {
    let t0 = PerfClock.now()
    let startedAt = Date()

    let files = (try? fm.contentsOfDirectory(at: payloadsDir, includingPropertiesForKeys: nil)) ?? []
    var deleted = 0

    for f in files where f.pathExtension.lowercased() == "bin" {
      let hash = f.deletingPathExtension().lastPathComponent
      do {
        let rc = try index.payloadRefCount(hash: hash)
        if rc <= 0 {
          try? fm.removeItem(at: f)
          try? index.deletePayloadRow(hash: hash)
          deleted += 1
        }
      } catch {
        // index doesn't know it -> remove file
        try? fm.removeItem(at: f)
        deleted += 1
      }
    }

    metrics.emit(.init(
      op: .garbageCollect,
      startedAt: startedAt,
      durationMs: PerfClock.ms(since: t0),
      bytesWritten: 0,
      bytesRead: 0,
      dedupeHit: false,
      filesDeleted: deleted
    ))
  }

  // MARK: - Index Query Helpers (optional convenience)

  public func listArtifacts(taskId: String) async throws -> [ArtifactIndexSQLite.ArtifactRow] {
    try index.listArtifacts(taskId: taskId)
  }

  public func latestArtifacts(kind: ArtifactKind, limit: Int) async throws -> [ArtifactIndexSQLite.ArtifactRow] {
    try index.latestArtifacts(kind: kind.rawValue, limit: limit)
  }

  public func listRecentTasks(kind: ArtifactKind, limit: Int) async throws -> [String] {
    try index.listTasks(kind: kind.rawValue, limit: limit)
  }

  // MARK: - Private helpers

  private func loadArtifactMetaV2(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMetaV2 {
    let metaURL = metaURL(taskId: taskId, kind: kind, key: key)
    guard fm.fileExists(atPath: metaURL.path) else { throw StoreError.notFound }
    let data = try Data(contentsOf: metaURL)
    return try decoder.decode(ArtifactMetaV2.self, from: data)
  }

  private func metaURL(taskId: String, kind: ArtifactKind, key: String) -> URL {
    let tid = PathSanitizer.safePathComponent(taskId)
    let knd = PathSanitizer.safePathComponent(kind.rawValue)
    let safeKey = PathSanitizer.safePathComponent(key)

    return tasksDir
      .appendingPathComponent(tid, isDirectory: true)
      .appendingPathComponent(knd, isDirectory: true)
      .appendingPathComponent("\(safeKey).meta.json", isDirectory: false)
  }

  private func removePayloadCompletely(hash: String) throws {
    let rel = try index.payloadPath(hash: hash)
    let url = root.appendingPathComponent(rel, isDirectory: false)
    try? fm.removeItem(at: url)
    try index.deletePayloadRow(hash: hash)
  }

  private func relativePath(from base: URL, to target: URL) -> String {
    let basePath = base.standardizedFileURL.path
    let targetPath = target.standardizedFileURL.path
    if targetPath.hasPrefix(basePath) {
      let rel = String(targetPath.dropFirst(basePath.count))
      return rel.hasPrefix("/") ? String(rel.dropFirst()) : rel
    }
    return targetPath
  }

  private func boolTag(_ tags: [String: String], _ key: String, default def: Bool) -> Bool {
    guard let v = tags[key]?.lowercased() else { return def }
    if v == "1" || v == "true" || v == "yes" { return true }
    if v == "0" || v == "false" || v == "no" { return false }
    return def
  }
}


11) 使用示例(你直接复制到项目里跑)

A) 在任务执行里(booting + 保存 result + 保存 events)

import Foundation

struct ChatResult: Codable {
  let messageId: String
  let text: String
  let model: String
}

struct TaskEvent: Codable {
  let t: Date
  let level: String
  let msg: String
}

func runChatTask(store: ArtifactStoreTool, taskId: String) async throws {
  try await store.bootStart(
    taskId: taskId,
    taskType: "chat.completion",
    tags: ["feature": "AIChats"]
  )

  do {
    let result = ChatResult(messageId: "m1", text: "hello", model: "gpt-4.1")
    let events = [
      TaskEvent(t: Date(), level: "info", msg: "started"),
      TaskEvent(t: Date(), level: "info", msg: "finished")
    ]

    let resultKey = ArtifactKeyFactory.numeric(step: 1, name: "final", domain: "chat")
    _ = try await (store as? FileArtifactStoreAdvanced)?.saveCodableArtifact(
      result,
      taskId: taskId,
      kind: .result,
      key: resultKey,
      policy: .init(
        ttl: 60 * 60 * 24 * 7,
        enableDedupe: true,
        schemaVersion: 3,
        tags: [
          "encrypt": "1",
          "compress": "1"
        ]
      )
    ) ?? store.saveCodableArtifact(
      result,
      taskId: taskId,
      kind: .result,
      key: resultKey.normalized,
      policy: .init(ttl: 60 * 60 * 24 * 7, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
    )

    let evKey = ArtifactKeyFactory.numeric(step: 1, name: "events", domain: "chat")
    _ = try await (store as? FileArtifactStoreAdvanced)?.saveCodableArtifact(
      events,
      taskId: taskId,
      kind: .eventLog,
      key: evKey,
      policy: .init(ttl: 60 * 60 * 24 * 3, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
    ) ?? store.saveCodableArtifact(
      events,
      taskId: taskId,
      kind: .eventLog,
      key: evKey.normalized,
      policy: .init(ttl: 60 * 60 * 24 * 3, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
    )

    try await store.bootFinish(taskId: taskId, status: .finished, note: nil)
  } catch {
    try await store.bootFinish(taskId: taskId, status: .failed, note: "\(error)")
    throw error
  }
}

说明:

你如果只用 FileArtifactStoreAdvanced,就直接用它的 ArtifactKey 重载更干净。

我这里写了一个兼容写法,让你未来可以把 store 换成别的实现。


B) App 启动恢复未完成任务

func recoverOnLaunch(store: ArtifactStoreTool) async {
  do {
    let booting = try await store.listBootingTasks()
    for r in booting {
      print("未完成任务:", r.taskId, r.taskType, r.startedAt)
      // 你可以选择自动重跑或标记 cancelled
      // try await store.bootFinish(taskId: r.taskId, status: .cancelled, note: "Recovered by app")
    }
  } catch {
    print("recover failed:", error)
  }
}


C) 维护:过期清理 + GC

func maintain(store: ArtifactStoreTool) async {
  do {
    try await store.cleanupExpiredArtifacts()
    try await store.garbageCollectPayloads()
  } catch {
    print("maintain failed:", error)
  }
}


12) 你落地时可能遇到的 2 个“工程设置”注意点

  1. SQLite3

  • 这套用的是 import SQLite3。多数 iOS 工程默认可用。

  • 如果你遇到链接错误:在 target 的 Build Phases → Link Binary With Libraries 加 libsqlite3.tbd。

  1. Compression / CryptoKit / Security

  • iOS/macOS 都可用,一般不需要额外设置。


如果你希望我下一步再把它进一步“AIChats 任务化到极致”,我建议加一个模块:

  • ArtifactMaintenanceTask(也是任务/skill)

    • 自检:index vs 文件一致性

    • 修复:缺 meta、缺 payload 的悬挂引用

    • 生成一份维护报告 artifact(便于你在 Developer Settings 里展示)

你说一句“把维护任务也给我”,我就按你现在的 store 结构直接补上(同样一整套可编译)。


评论