个性软件torrid总结

项目文档展示

TORRID文档

仓库地址: zx1360/Torrid: 仅安卓端的自用多功能软件. 打卡, 随笔, 看漫画, 与monarch项目联动查看pc库存和媒体文件等功能.

应用信息

关于TORRID

一、开源

[zx1360/Torrid] [https://github.com/zx1360/Torrid

二、数据安全

隐私

所有用户数据存于本地而不会被外部获取

备份

可通过与我另外一个库中开源软件monarch联动, 实现备份到pc上.

格式为json, 适合阅读/修改.

三、应用干净

卸载应用即删除所有数据不留痕. 不保留任何配置信息和数据.

==因此, 未备份的数据会在应用卸载后真正消失.==

  • 打卡/随笔等数据保存于内部应用私有目录
  • 相应的图片等文件保存于外部应用私有目录

PS: 除了某些页面手动点击"保存"按钮后, 保存在外部公共空间. (为了保存的图片能够被手机自带的相册识别到).

四、后台友好

CPU友好: 只有用户操作才会进行响应的操作.

处于后台时不会运行其他行为. (指仍旧活跃的后台代码)

**内存友好: **

不知道, 感觉应该不占内存.

注意事项

真正把想法做出成品来才感受得到, 要做到"人性化"真的是费时又费力. 但这一点又恰是最影响使用体验的. 请避免以下行为防止应用出错.

(我没测试过会发生什么情况).

  • 不要随意删改应用下的所有文件名/目录名. (不过手机一般操作不到应用目录).
  • 不要进行奇怪的操作查看有无奇怪的反应.

另外说明

本应用是出于自用+练手目的制作的, 目标场景以我的需求出发. 比如我喜欢做任务一样地每天做点事然后回顾的时候有成就感, 于是有了积微页来打卡. 喜欢每天记点东西就有了随笔页写东西.

25年八月中旬了解到flutter开始到目前26年二月中旬, 一旦入门感觉写起来还挺有意思的, 甚至当初开发随笔页时十多天小半个月还没弄出来虽然挫败但是最终做出也挺有乐趣的. 另外也略微了解到了应用开发相关知识感觉受益匪浅. 不过到目前库存页开发完毕之后应该就搁置仅作维护以及偶尔优化了. 原因有二:

  • 一是确实我所期望的**==满足我个性需求的好用的小工具应用==**差不多完善了.
  • 二是最近一个月来用教育福利获得的github copilot额度开发, 效果极好, 甚至已经到了额度用完就没啥自己手写的想法的程度了. 不得不说, claude opus真王朝了吧

部分功能**(如库存页, 漫画页, 藏品页以及本地数据的备份和同步)需要搭配我的另一个项目monarch**使用, 他是一个运行在电脑上的Go程序, 用以处理本应用的http请求.

页面介绍

主页

可自定义主页背景, 默认简约渐变背景

积微页

设置若干任务然后坚持每天完成!

随笔页

写日记、记录回忆、一天的经历等, 并打上标签, 可追加留言.

库存页

待完成, (借助自建服务器)

阅读页

借助公共在线api, 提供即时信息如每日精炼新闻, epic免费游戏, 各app的热搜.

其他页

漫画页

本地或在线阅读漫画 (借助自建服务器)

藏品页

回顾以往自己的媒体文件, 标记删除以及打标签等 (借助自建服务器)

用户页

偏好设置

设置主页和侧边菜单的背景, 以及设置侧边菜单的顶部格言部分.

数据备份 (借助自建服务器)

设定服务端的地址端口, 备份/同步数据.

反馈问题

不一定会看, 也不一定会改, 因为我这么长时间用下来没问题挺顺手的 :D

MONARCH文档

仓库地址: zx1360/monarch: Go编写的http服务器, 用以支持我另一个项目’torrid’的网络请求.

关于MONARCH

GO编写的http服务器, 用以支持另一个项目TORRID的网络请求.

一、开源

zx1360/monarch: Go编写的http服务器, 用以支持我另一个项目’torrid’的网络请求.

二、高性能 无感知后台运行

由于使用Go编写, 相比java/python开发的http服务器占用内存少, 响应高效, 后台友好.

可以编写vbs脚本放入开机启动目录以无窗口模式静默运行, 平时几乎不占用性能损耗

三、应用干净

一体式以独立目录存在, 所有数据均不存入C盘的文档目录之类的, 仅存于所在的目录中, 可方便地彻底删除.

==因此, 应当自行做好数据的管理防止丢失数据==

依赖的数据库

PostgreSQL18.0

如果要正确运行还需本地运行一个postgres数据库, (自用场景后台运行可忽略性能占用). 下面附上用到的数据表结构以及触发器定义.

漫画页数据库

建表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
-- 漫画主表(存储单本漫画信息)
CREATE TABLE IF NOT EXISTS comic_books (
    id UUID PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    chapter_count INTEGER NOT NULL DEFAULT 0,
    image_count INTEGER NOT NULL DEFAULT 0, -- 该漫画总图片数
    cover_image TEXT -- 封面图相对路径(第一个章节的第一张图)
);

-- 漫画章节表(存储单本漫画的章节信息)
CREATE TABLE IF NOT EXISTS comic_chapters (
    id UUID PRIMARY KEY,
    comic_id VARCHAR(64) NOT NULL,
    dir_name VARCHAR(255) NOT NULL, -- 格式:001_章节名
    chapter_index INTEGER NOT NULL,
    image_count INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (comic_id) REFERENCES comic_books(id) ON DELETE CASCADE
);

-- 漫画图片表(存储章节下的图片路径及属性)
CREATE TABLE IF NOT EXISTS comic_images (
    id UUID PRIMARY KEY,
    chapter_id VARCHAR(64) NOT NULL,
    image_path TEXT NOT NULL,
    sort_num INTEGER NOT NULL, -- 图片排序号(1、2、3...)
    width INTEGER NOT NULL, -- 图片宽度
    height INTEGER NOT NULL, -- 图片高度
    FOREIGN KEY (chapter_id) REFERENCES comic_chapters(id) ON DELETE CASCADE
);

-- 漫画汇总表(存储所有漫画的统计信息)
CREATE TABLE IF NOT EXISTS comic_summary (
    id VARCHAR(64) PRIMARY KEY DEFAULT 'comic_total_metadata',
    title VARCHAR(255) NOT NULL DEFAULT '漫画信息元数据',
    book_count INTEGER NOT NULL DEFAULT 0,
    total_chapter_count INTEGER NOT NULL DEFAULT 0,
    total_image_count INTEGER NOT NULL DEFAULT 0,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT comic_summary_single_row CHECK (id = 'comic_total_metadata')
);

-- 章节表:comic_id查询+排序索引
CREATE INDEX if not exists idx_comic_chapters_comic_id ON comics.comic_chapters (comic_id, chapter_index);
-- 图片表:chapter_id查询+排序索引
CREATE INDEX if not exists idx_comic_images_chapter_id ON comics.comic_images (chapter_id, sort_num);

藏品页数据库

建表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
-- 文件信息表media_assets
CREATE TABLE
IF
	NOT EXISTS media_assets (
		ID UUID PRIMARY KEY,
		created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,-- 入库时间
		updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,-- 修改时间
		captured_at TIMESTAMPTZ NOT NULL,-- 优先级: EXIF > 修改时间 > 创建时间
		file_path TEXT NOT NULL,-- 存储中的相对路径
		thumb_path TEXT,-- 生成的缩略图/封面图相对路径
		preview_path TEXT,-- 生成的预览图相对路径
		hash BYTEA NOT NULL UNIQUE,-- SHA-256 用于去重
		size_bytes BIGINT NOT NULL DEFAULT 0,
		mime_type TEXT,
		is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
		sync_count INTEGER NOT NULL DEFAULT 0,-- 表示该媒体文件记录从移动端被同步到服务端的次数
		group_id UUID DEFAULT NULL,-- 指向“主文件”的 ID。如果不为空,代表该文件被捆绑.
		message TEXT default null,
		CONSTRAINT fk_group_id FOREIGN KEY ( group_id ) REFERENCES media_assets ( ID ) ON DELETE 
	SET NULL 
	);
-- 为media_assets添加索引
CREATE INDEX
IF
	NOT EXISTS idx_media_assets_sync_captured ON media_assets ( is_deleted, sync_count, captured_at );
CREATE INDEX
IF
	NOT EXISTS idx_media_assets_updated_at ON media_assets ( updated_at );-- 更新日期排序.
CREATE INDEX
IF
	NOT EXISTS idx_media_assets_group_id ON media_assets ( group_id );--主文件查询
CREATE INDEX
IF
	NOT EXISTS idx_media_assets_mime_type ON media_assets ( mime_type );--按文件类型排序

-- 标签表tags, 树状结构
CREATE TABLE
IF
	NOT EXISTS tags (
		ID UUID PRIMARY KEY,
		created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
		updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
		NAME TEXT NOT NULL,
		parent_id UUID,-- 记录其父标签, 根节点为 Null.
		full_path TEXT,-- 冗余字段用于快速搜索, 例如 "Family/2023/Xmas"). 级联更新.
		CONSTRAINT fk_parent_id FOREIGN KEY ( parent_id ) REFERENCES tags ( ID ) ON DELETE CASCADE,
		CONSTRAINT uk_tag_name_parent UNIQUE ( NAME, parent_id ) 
	);
-- 为tags添加索引
CREATE INDEX
IF
	NOT EXISTS idx_tags_parent_id ON tags ( parent_id );-- 按父标签查询子标签
CREATE INDEX
IF
	NOT EXISTS idx_tags_full_path ON tags ( full_path );-- 按完整路径快速搜索

-- 文件标签关联表media_tag_links
CREATE TABLE
IF
	NOT EXISTS media_tag_links (
		media_id UUID,
		tag_id UUID,
		PRIMARY KEY ( tag_id, media_id ),
		CONSTRAINT fk_media_id FOREIGN KEY ( media_id ) REFERENCES media_assets ( ID ) ON DELETE CASCADE,
		CONSTRAINT fk_tag_id FOREIGN KEY ( tag_id ) REFERENCES tags ( ID ) ON DELETE CASCADE 
	);
-- 为media_tag_links添加索引
CREATE INDEX
IF
	NOT EXISTS idx_media_tag_links_media_id ON media_tag_links ( media_id );-- 按媒体ID查询关联媒体

触发器

触发器-自增同步次数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 1. 创建触发器函数:仅在未显式更新 sync_count 时自动自增
CREATE OR REPLACE FUNCTION increment_sync_count()
RETURNS TRIGGER AS $$
BEGIN
    -- 只在记录被更新时执行(排除 INSERT 场景)
    IF TG_OP = 'UPDATE' THEN
        -- 核心逻辑:检查是否显式更新了 sync_count 字段
        -- (OLD.sync_count IS DISTINCT FROM NEW.sync_count) 表示字段值被主动修改过
        -- 取反后,仅当字段未被显式更新时才自增
        IF NOT (OLD.sync_count IS DISTINCT FROM NEW.sync_count) THEN
            NEW.sync_count = OLD.sync_count + 1;
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- 2. 重建触发器(如果触发器已存在,先删除再创建)
DROP TRIGGER IF EXISTS trigger_media_assets_sync_count ON media_assets;
CREATE TRIGGER trigger_media_assets_sync_count
BEFORE UPDATE ON media_assets
FOR EACH ROW
EXECUTE FUNCTION increment_sync_count();

触发器-更新时间

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- 创建通用的updated_at自动更新触发器函数
CREATE 
	OR REPLACE FUNCTION update_updated_at_column ( ) RETURNS TRIGGER AS $$ BEGIN
		NEW.updated_at := CURRENT_TIMESTAMP;
	RETURN NEW;
	
END;
$$ LANGUAGE plpgsql SECURITY DEFINER 
SET search_path = PUBLIC;

-- 为media_assets表添加updated_at触发器
CREATE TRIGGER trigger_media_assets_updated_at BEFORE UPDATE ON media_assets FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column ( );

-- 为tags表添加updated_at触发器
CREATE TRIGGER trigger_tags_updated_at BEFORE UPDATE ON tags FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column ( );

触发器-tags路径级联更新

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
-- ============================================
-- 1. 先删除可能存在的旧触发器
-- ============================================
DROP TRIGGER IF EXISTS trg_tags_before_ins_upd ON gallery.tags;
DROP TRIGGER IF EXISTS trg_tags_after_upd ON gallery.tags;

-- ============================================
-- 2. 创建函数(指定 schema 为 gallery)
-- ============================================
CREATE OR REPLACE FUNCTION gallery.tags_compute_full_path(p_name TEXT, p_parent_id UUID)
RETURNS TEXT
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
  v_parent_path TEXT;
BEGIN
  IF p_parent_id IS NULL THEN
    RETURN p_name;
  END IF;

  SELECT full_path INTO v_parent_path
  FROM gallery.tags
  WHERE id = p_parent_id;

  IF v_parent_path IS NULL THEN
    RETURN p_name;
  END IF;

  RETURN v_parent_path || '/' || p_name;
END;
$$;

-- ============================================
-- 3. 递归级联更新子节点
-- ============================================
CREATE OR REPLACE FUNCTION gallery.tags_cascade_update_full_path(p_id UUID)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
  v_child RECORD;
  v_new_path TEXT;
BEGIN
  FOR v_child IN
    SELECT id, name FROM gallery.tags WHERE parent_id = p_id
  LOOP
    SELECT full_path || '/' || v_child.name INTO v_new_path
    FROM gallery.tags WHERE id = p_id;

    UPDATE gallery.tags
    SET full_path = v_new_path,
        updated_at = CURRENT_TIMESTAMP
    WHERE id = v_child.id;

    PERFORM gallery.tags_cascade_update_full_path(v_child.id);
  END LOOP;
END;
$$;

-- ============================================
-- 4. BEFORE INSERT/UPDATE 触发器函数
-- ============================================
CREATE OR REPLACE FUNCTION gallery.tags_before_ins_upd()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.full_path := gallery.tags_compute_full_path(NEW.name, NEW.parent_id);
  NEW.updated_at := CURRENT_TIMESTAMP;
  RETURN NEW;
END;
$$;

-- ============================================
-- 5. AFTER UPDATE 触发器函数
-- ============================================
CREATE OR REPLACE FUNCTION gallery.tags_after_upd()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  IF OLD.name IS DISTINCT FROM NEW.name 
     OR OLD.parent_id IS DISTINCT FROM NEW.parent_id THEN
    PERFORM gallery.tags_cascade_update_full_path(NEW.id);
  END IF;
  RETURN NULL;
END;
$$;

-- ============================================
-- 6. 绑定触发器
-- ============================================
CREATE TRIGGER trg_tags_before_ins_upd
BEFORE INSERT OR UPDATE OF name, parent_id
ON gallery.tags
FOR EACH ROW
EXECUTE FUNCTION gallery.tags_before_ins_upd();

CREATE TRIGGER trg_tags_after_upd
AFTER UPDATE OF name, parent_id
ON gallery.tags
FOR EACH ROW
EXECUTE FUNCTION gallery.tags_after_upd();

其他说明

更详细的用法参考TORRID应用的使用:

[zx1360/Torrid] [https://github.com/zx1360/Torrid

MONARCH_GIZMOS文档

仓库地址: zx1360/monarch_gizmos: 另一个项目’monarch’的辅助CLI命令行程序, 处理原始数据并写入本地数据库以供http服务器使用.

关于GIZMOS

Go程序, 主要承载另一个项目MONARCH的处理.

简单说就是处理原始数据并写入数据库, 使得monarch能够响应.

  • 实现漫画文件的检索写入本地postgres数据库.
  • 本地媒体文件遍历写入本地postgres数据库, 并整理归档. 以及软删除客户端响应的被标记为删除的文件.

其他说明

虽然与monarch同为Go编写且完全与其联动依赖, 但不与monarch集成而是独立为一个程序主要考虑这些都是偶尔才用到的、一次性的使用场景. 也许之后会集成, 再说吧.

重要AI prompt展示

展示理由以及说明

自从25年12月得到github copilot教育福利的每月免费额度后, 渐渐地越来越依赖这个开发, 尤其是之后的claude opus 4.5, 强的离谱, 效果好的离谱.

下面给出一些我开发时经常附上的追加要求, 对于规范他的代码以及避免可能让人恼火的问题挺有帮助的. 以及一些具体需求开发时的对话内容供参考:

提示词经验之谈 (不保真 0v0)

  • ⭐当希望ai在你开发到一半的项目上引入新功能/修改现有功能, 加上这么一句能让它动手前对你现有代码理解更深. 你能分析出我现有项目的实现逻辑和把握我的需求吗? 理解之后, 为我...理解我项目中这个模块的逻辑, 然后按照我的要求...

  • ⭐在我给出较复杂/较多点的要求或者是基于已有项目进行新增/改动时, 我一般会在最后加上这一段, 否则它似乎很轻易地就会改动原先代码引入错误.

    1
    2
    3
    4
    5
    6
    
    必须遵守的要求:
    做好相应的适配, ui简约美观, 交互友好.
    考虑一些特殊情况, 做好应对防止应用异常.
    不对已有功能引入任何破坏性或者可能导致不可预知影响的修改.
    修改直到项目无错且能正确按照我的预期运行.
    注意代码可维护性.
    
  • 一次对话含多个要求时显式表明"1., 2.", 其下的重点要求单独一段而不要与其他要求通过逗号/句号处于同一段:

    1
    2
    3
    4
    5
    6
    7
    8
    
    我希望对于我的打卡和随笔页相关数据加入"心情记录" 比如用几个表示天气的小表情表示心情(或者其他更适合的形式). 
    考虑复用性, 两处(甚至以后的其他模块)都可使用, 
    该内容是可选性的, 如果有则记录, 无则不写入数据. 
    考虑拓展性以及更改性, 也许之后我会更改心情的种类
    另外由于这两部分我现已有数据, 所以增加修改代码以及适配现有代码务必谨慎并且考虑周全!
    1. 分析该页面相关逻辑, 我希望做出如下修改: 每天的打卡记录数据类添加一个字段(我应用中现已有该数据类的一些数据, 不知道对于数据类的新加入字段会不会造成问题? 如果不会的话就加入, 做好原先该字段为空的数据的展示问题的兼容.) 并做好相应的ui逻辑等, "// TODO: 保存按钮左边有一片区域空着, 也许放点小表情(天气图标) 表示当天心情?"符合这个信息, 
    2. 同样对随笔页面的加入心情记录, (仅写入随笔可用, 追加留言不必).
    返回的修改结果应无错并且可以按预期运行, 修改直到代码正确!
    

举个栗子

开发需求

开发torrid的gallery藏品页模块, 它是一个用以回顾我保存于电脑的所有视频/图片文件并打标签和标记删除的工具, 分为三部分:

  • torrid客户端, 提供内容呈现和交互.
  • monarch服务端, 实际的文件存储, 处理http请求.
  • monarch_gizmos, 辅助CLI程序, 处理原始文件写入数据库以及实际对标记删除的文件进行软删除.

以md文件记录需求

项目基准文档.md

项目基准文档


角色设定 (Role Definition)

你是一位精通 Golang (后端)、Flutter (移动端)、关系型数据库 (PostgreSQL 和 Flutter 端的 sqflite) 以及 离线优先 (Offline-First) 架构的全栈架构师。

你的任务是实现一个运行在局域网内的个人媒体管理系统(单服务器、单客户端场景)。


核心目标 (Core Objective)

构建一个私有化部署系统,用于回顾、打标签、分类和整理个人的海量图片/视频。系统需支持 Android 端在 局域网 (LAN) 环境下的离线操作,并保证数据的最终一致性。


技术栈 (Tech Stack)

层级 技术 说明
Database PostgreSQL 全表主键使用 UUIDv4 以支持离线生成无冲突 ID
Backend - gallery Go (CLI) 负责文件扫描、缩略图生成、物理文件归档、数据库入库
Backend - monarch Go (HTTP) 负责 API 响应、文件服务、数据同步
Frontend - torrid Flutter 使用 sqflite 进行本地持久化和状态管理以及暂存对媒体文件的所有操作

数据一致性与约束 (Data Consistency)

Bundling Deletion(捆绑删除)

  • 删除"主文件" (Group Lead) 时,将组内所有文件的 is_deleted 设为 true,即一并删除所有组内文件
  • 将某文件记录的 group_id 字段设为 null 表示脱离捆绑

数据库文档.md

Gallery 数据库 Schema 文档

模式名: gallery

设计原则: 为确保数据一致性和原子性,建议合理使用事务来确保操作完整性。


一、数据表定义

1. 文件信息表 media_assets

存储媒体文件的核心信息,包括存储路径、哈希值、缩略图等。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE TABLE IF NOT EXISTS media_assets (
    ID UUID PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,  -- 入库时间
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,  -- 修改时间
    captured_at TIMESTAMPTZ NOT NULL,                           -- 优先级: EXIF > 修改时间 > 创建时间
    file_path TEXT NOT NULL,                                    -- 存储中的相对路径
    thumb_path TEXT,                                            -- 生成的缩略图/封面图相对路径
    preview_path TEXT,                                          -- 生成的预览图相对路径
    hash BYTEA NOT NULL UNIQUE,                                 -- SHA-256 用于去重
    size_bytes BIGINT NOT NULL DEFAULT 0,
    mime_type TEXT,
    is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
    sync_count INTEGER NOT NULL DEFAULT 0,                      -- 表示该媒体文件记录从移动端被同步到服务端的次数
    group_id UUID DEFAULT NULL,                                 -- 指向"主文件"的 ID。如果不为空,代表该文件被捆绑
    message TEXT DEFAULT NULL,
    CONSTRAINT fk_group_id FOREIGN KEY (group_id) REFERENCES media_assets (ID) ON DELETE SET NULL
);

索引定义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- 按删除状态、同步次数和拍摄时间查询
CREATE INDEX IF NOT EXISTS idx_media_assets_sync_captured 
    ON media_assets (is_deleted, sync_count, captured_at);

-- 按更新日期排序
CREATE INDEX IF NOT EXISTS idx_media_assets_updated_at 
    ON media_assets (updated_at);

-- 主文件查询
CREATE INDEX IF NOT EXISTS idx_media_assets_group_id 
    ON media_assets (group_id);

-- 按文件类型排序
CREATE INDEX IF NOT EXISTS idx_media_assets_mime_type 
    ON media_assets (mime_type);

2. 标签表 tags

树状结构的标签系统,支持层级关系。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
CREATE TABLE IF NOT EXISTS tags (
    ID UUID PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    NAME TEXT NOT NULL,
    parent_id UUID,                                             -- 记录其父标签, 根节点为 Null
    full_path TEXT,                                             -- 冗余字段用于快速搜索 (例如 "Family/2023/Xmas")
    CONSTRAINT fk_parent_id FOREIGN KEY (parent_id) REFERENCES tags (ID) ON DELETE CASCADE,
    CONSTRAINT uk_tag_name_parent UNIQUE (NAME, parent_id)
);

索引定义

1
2
3
4
5
6
7
-- 按父标签查询子标签
CREATE INDEX IF NOT EXISTS idx_tags_parent_id 
    ON tags (parent_id);

-- 按完整路径快速搜索
CREATE INDEX IF NOT EXISTS idx_tags_full_path 
    ON tags (full_path);

关联媒体文件与标签的多对多关系表。

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS media_tag_links (
    media_id UUID,
    tag_id UUID,
    PRIMARY KEY (tag_id, media_id),
    CONSTRAINT fk_media_id FOREIGN KEY (media_id) REFERENCES media_assets (ID) ON DELETE CASCADE,
    CONSTRAINT fk_tag_id FOREIGN KEY (tag_id) REFERENCES tags (ID) ON DELETE CASCADE
);

索引定义

1
2
3
-- 按媒体ID查询关联标签
CREATE INDEX IF NOT EXISTS idx_media_tag_links_media_id 
    ON media_tag_links (media_id);

二、触发器定义

1. 通用更新时间触发器

自动维护 updated_at 字段的触发器函数。

1
2
3
4
5
6
7
8
-- 创建通用的 updated_at 自动更新触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at := CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = PUBLIC;

绑定到数据表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- 为 media_assets 表添加 updated_at 触发器
CREATE TRIGGER trigger_media_assets_updated_at
    BEFORE UPDATE ON media_assets
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- 为 tags 表添加 updated_at 触发器
CREATE TRIGGER trigger_tags_updated_at
    BEFORE UPDATE ON tags
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

2. 标签 full_path 级联更新触发器

维护标签层级路径的自动计算与级联更新。

辅助函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- 计算单条标签的 full_path
CREATE OR REPLACE FUNCTION tags_compute_full_path(p_name TEXT, p_parent_id UUID)
RETURNS TEXT
LANGUAGE plpgsql AS $$
DECLARE
    v_parent_path TEXT;
BEGIN
    IF p_parent_id IS NULL THEN
        RETURN p_name;
    END IF;

    SELECT full_path INTO v_parent_path
    FROM tags
    WHERE id = p_parent_id;

    RETURN v_parent_path || '/' || p_name;
END;
$$;

递归更新函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 递归级联更新子节点 full_path
CREATE OR REPLACE FUNCTION tags_cascade_update_full_path(p_id UUID)
RETURNS VOID
LANGUAGE plpgsql AS $$
DECLARE
    v_child UUID;
    v_name TEXT;
    v_parent_id UUID;
BEGIN
    -- 更新当前节点
    SELECT name, parent_id INTO v_name, v_parent_id
    FROM tags
    WHERE id = p_id;

    UPDATE tags
    SET full_path = tags_compute_full_path(v_name, v_parent_id),
        updated_at = CURRENT_TIMESTAMP
    WHERE id = p_id;

    -- 递归更新所有子节点
    FOR v_child IN
        SELECT id FROM tags WHERE parent_id = p_id
    LOOP
        PERFORM tags_cascade_update_full_path(v_child);
    END LOOP;
END;
$$;

触发器函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
-- BEFORE INSERT/UPDATE:先计算本节点 full_path
CREATE OR REPLACE FUNCTION tags_before_ins_upd()
RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
    NEW.full_path := tags_compute_full_path(NEW.name, NEW.parent_id);
    NEW.updated_at := CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$;

-- AFTER INSERT/UPDATE:触发级联更新子节点
CREATE OR REPLACE FUNCTION tags_after_ins_upd()
RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
    PERFORM tags_cascade_update_full_path(NEW.id);
    RETURN NULL;
END;
$$;

触发器绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 绑定触发器
CREATE TRIGGER trg_tags_before_ins_upd
    BEFORE INSERT OR UPDATE OF name, parent_id
    ON tags
    FOR EACH ROW
    EXECUTE FUNCTION tags_before_ins_upd();

CREATE TRIGGER trg_tags_after_ins_upd
    AFTER INSERT OR UPDATE OF name, parent_id
    ON tags
    FOR EACH ROW
    EXECUTE FUNCTION tags_after_ins_upd();

3. 同步次数自增触发器

每次更新 media_assets 记录时自动递增 sync_count 字段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- 创建触发器函数:更新时自动自增 sync_count
CREATE OR REPLACE FUNCTION increment_sync_count()
RETURNS TRIGGER AS $$
BEGIN
    -- 只在记录被更新时执行(排除 INSERT 场景)
    IF TG_OP = 'UPDATE' THEN
        -- 将新记录的 sync_count 设置为 旧值 + 1
        NEW.sync_count = OLD.sync_count + 1;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

触发器绑定

1
2
3
4
5
-- 创建触发器,绑定到 media_assets 表的 UPDATE 事件
CREATE TRIGGER trigger_media_assets_sync_count
    BEFORE UPDATE ON media_assets
    FOR EACH ROW
    EXECUTE FUNCTION increment_sync_count();

三、字段说明速查表

media_assets 表

字段名 类型 说明
ID UUID 主键,唯一标识
created_at TIMESTAMPTZ 入库时间
updated_at TIMESTAMPTZ 修改时间(自动更新)
captured_at TIMESTAMPTZ 拍摄时间,优先级:EXIF > 修改时间 > 创建时间
file_path TEXT 存储中的相对路径
thumb_path TEXT 缩略图/封面图相对路径
preview_path TEXT 预览图相对路径
hash BYTEA SHA-256 哈希,用于去重
size_bytes BIGINT 文件大小(字节)
mime_type TEXT MIME 类型
is_deleted BOOLEAN 软删除标记
sync_count INTEGER 从移动端同步到服务端的次数
group_id UUID 指向主文件的 ID,用于文件捆绑
message TEXT 附加消息

tags 表

字段名 类型 说明
ID UUID 主键
created_at TIMESTAMPTZ 创建时间
updated_at TIMESTAMPTZ 修改时间(自动更新)
NAME TEXT 标签名称
parent_id UUID 父标签 ID,根节点为 NULL
full_path TEXT 完整路径(冗余字段),如 “Family/2023/Xmas”
字段名 类型 说明
media_id UUID 媒体文件 ID
tag_id UUID 标签 ID

文档生成时间: 2026-02-17

torrid文档.md

Torrid

Flutter App(离线优先)

前端应用:Flutter + sqflite
逻辑:闭环的资源管理与交互


Local Data Structure (sqflite)

本地数据表结构

表名 说明
media_assets 镜像服务端的 media_assets 表(轻量级元数据)
tags 存储标签记录(初始为从服务端获取的初始值)
media_tag_links 存储媒体与标签的关联记录(从服务端增量获取写入)

注意:表约束与服务端的对应表保持一致。由于客户端本地的表数据量级远小于服务端,可不必过于在意性能优化。


The “Review Cycle”(核心业务闭环)

1. Fetch(获取)

用户点击"加载新一批"(可自指定数量)。App 请求 monarch,将获取到的 JSON 数据按逻辑存储到本地 sqflite(基本是原数据表的镜像)。

2. Cache(缓存)

App 下载这批文件的缩略图 + 预览图存入本地存储(外部应用私有目录下的 /gallery/yyyy-mm/...)。

3. Review(审阅)

用户滑动阅览、打标签、标记删除。

4. Bundling(捆绑)

用户长按选 A, B, C → 设 A 为主:

  • 逻辑:更新 B, C 的 group_id = A.id
  • UI 表现:隐藏 B, C,只显示 A(带堆叠图标)

5. Tagging(打标签)

对 A 打标签:

  • 建议逻辑:标签仅关联 A,但搜索时 B, C 视为隐式包含

6. Push(推送)

App 将经过操作的文件记录和 media_tag_links 表中与这些文件记录关联的文件-标签关联记录以及全量标签记录转为 JSON 上传服务端。

7. Cleanup(空间闭环)

当 monarch 确认同步成功后,App 自动删除上传数据中相关的媒体文件的缩略图和预览图以及相关数据表中的相关数据记录,为下一批文件腾出手机空间。


数据同步追加详细说明

客户端向服务端获取文件信息

响应包含:

  • 指定量的文件信息记录
  • 全量 tags 信息记录
  • 与响应中含有的文件信息关联的所有 media_tag 信息记录

客户端向服务端提交信息

服务端处理逻辑:

  • 文件信息记录:采用这些数据对服务端数据库对应记录(除了 file_path 字段)进行更新
  • Tags:删除原先所有的,以现在这些新数据为根据全部写入
  • Media_tag 记录数据:服务端数据库 media_tag 表中含有新传入 media 记录的 ID 的全部删除,然后再写入现在传入的 media_tag 信息

更详细的说明

  1. 用户有可能对文件进行多轮次的下载,可能在 App 连续拉取好几批
  2. 客户端上传时 URL 中 offset 值为本地记录中存在的文件记录条数
  3. 服务端通过类似以下 SQL 确保能连续增量拉取:
    1
    2
    3
    4
    
    SELECT * FROM media_assets 
    WHERE is_deleted = false 
    ORDER BY sync_count ASC, captured_at ASC 
    LIMIT ('limit') OFFSET ('offset')
    
  4. 客户端向服务端增量覆盖

UI/UX

追求友好交互,画面简约美观

Review Mode

类似相册流或卡片堆叠。

详细信息

通过插件实现基本的缩放等操作,修改双击操作为查看文件详细信息。


技术要点

  • 本地轻量数据库(sqflite),有关数据库保持与服务端数据库表结构一致
  • 首次在局域网拉取快照(数据 + 缩略图/预览图)
  • 后续增量同步,支持离线队列与重试
  • 所有的打标签/标记删除等操作在本地离线实现,记录于与服务端数据表结构相同的 sqflite 中
  • 同步时增量上传,之后删除上传的 sqflite 中相关记录,这一步确保原子性
  • 与 monarch 的 HTTP 服务器对应的 API

API 响应数据格式示例

Endpoint: /api/gallery/batch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "media_assets": [
    {
      "id": "71ab794a-25cf-46a7-a3b8-622e37474e5d",
      "created_at": "2026-01-28T19:40:16.961975+08:00",
      "updated_at": "2026-01-28T19:40:16.961975+08:00",
      "captured_at": "2008-08-29T14:10:56+08:00",
      "file_path": "2008-08\\Super Sonico超级索尼子壁纸.jpg",
      "thumb_path": "2008-08\\Super Sonico超级索尼子壁纸_thumb.jpg",
      "preview_path": "2008-08\\Super Sonico超级索尼子壁纸_preview.jpg",
      "hash": "9xy9SF4oUH/E3CpFOU26qnJU4rcEClHEFMo5go9Lyus=",
      "size_bytes": 1942070,
      "mime_type": "image/jpeg",
      "is_deleted": false,
      "sync_count": 0,
      "group_id": null
    }
    ...
  ],
  "tags": null,
  "media_tag_links": null
}

注:tagsmedia_tag_links 值可能初始为空,不为空则为 JSON 格式的数据表中的数据的列表。


monarch文档.md

Monarch

Golang HTTP 服务器(在线 API)

后端程序 monarch:服务端 (HTTP API)
逻辑:智能分发与同步


API 端点

获取批量媒体数据

1
GET /api/gallery/batch?limit={limit}&offset={offset}

响应 JSON 格式的一批次的媒体文件信息、全量的标签数据记录以及 media_tag_link 表中与响应的媒体文件关联的所有文件-标签关系(如果有)。

参考查询代码

1
2
3
4
SELECT * FROM media_assets 
WHERE is_deleted = false 
ORDER BY sync_count ASC, captured_at ASC 
LIMIT ('limit') OFFSET ('offset')

下载原文件

1
GET /api/gallery/{id}/file

下载缩略图

1
GET /api/gallery/{id}/thumb

下载预览图

1
GET /api/gallery/{id}/preview

获取完整标签树

1
GET /api/gallery/tags

推送数据到服务端

1
POST /api/gallery/push

根据 App 发来的 JSON 格式的数据表记录数据更新数据库(media_assetsfile_path 字段不变)。

注意:同步数据到服务端时将每个媒体文件记录中 sync_count 字段加一。


核心特性

  • 提供"初次快照 + 增量同步"协议(面向局域网,离线友好)
  • 响应文件下载请求
  • 通过 Header 实现简单的 API Key 验证

gallery文档.md (monarch_gizmos)

Gallery

Golang 扫描/搬运器(离线批处理)

后端程序 gallery:工作节点 (CLI Tool)
逻辑:负责物理文件的摄入与生命周期管理


Ingestion Pipeline(摄入流水线 - 选项1)

深度遍历路径1(rawDir),获取所有图片/视频(.jpg, .webp, .jpeg, .png, .mp4, .gif 等所有可以称之为图片和视频的文件),进行如下操作:

1. 去重检查

  • 计算该文件的 SHA-256 哈希值
  • 如果数据库中有该记录,则将该文件移动到路径3(deletedDir),并跳过后续步骤进入下一个文件的操作

2. 元信息抽取

抽取媒体元信息:

  • 尺寸
  • 时长
  • 文件日期(优先级:EXIF > 修改时间 > 创建时间)
  • 文件哈希
  • 感知哈希等

3. 文件日期计算规则

选取文件的"创建时间"和"修改时间"中更早的那个。

4. 归档移动

yyyy-mm 归档移动(move)到路径2(mediaDir):

  • mediaDir 表示的目录下有若干 yyyy-mm 形式的文件夹,如果没有则创建
  • 记录状态等

5. 缩略图生成

如果是视频则选取时间上第10%的那一帧的图片,后续与一般图片相同处理。另外 .gif 之类的文件的压缩仍旧是"内容完整"的,仅对分辨率压缩。

5.1 网格展示缩略图(256×256)

  • 非方形原图则尽量选取中心部位
  • 等比缩放至生成图不留白(即可截取但不可留白)

5.2 预览图(最大边 512px)

按原图比例等比缩放到最大边为 512px。

6. 文件从属关系

逻辑上各缩略图是"从属"于原图的:

  • 后续执行"删除"操作时将原图和缩略图以及预览图同步移动到 deletedDir

7. 文件移动规则

文件类型 目标路径
原文件 Media/YYYY-MM/filename.ext
缩略图 thumbs/YYYY-MM/filename_thumb.ext
预览图 previews/YYYY-MM/filename_preview.ext

8. 数据入库

将各数据写入 PostgreSQL。


Execution Pipeline(执行流水线 - 选项2)

Delete Handling(删除处理)

  • 查询 is_deleted = true 的记录
  • 将物理文件移动到 deleted/ 目录(作为安全网,不立即物理删除)
  • 包括所有 group_id 为该文件 ID 的及相关文件的缩略图和预览图

Cleanup(清理)

清理空文件夹。


开发说明

  • 做好错误处理与一般的兜底以及打印输出提示信息
  • 遍历文件/写入数据库的批量优化,引入并发控制
  • 视频处理使用 FFmpeg(相关依赖已加入环境变量)

agent对话

1
2
我正在开发这个总项目的torrid相关部分, 请你查看我的五个项目文档和gallery目录下相关文件厘清我的项目逻辑. 重点关注标记"PS:" 和"TODO:" 的注释和重要的注释部分理会我的开发意图.
以推荐的项目组织结构和实现方式完成我的需求. 对我的开发要求有不明白的地方首先提问而不是立马着手做.

然后ai列举了几点不明确的地方, 然后分别详细说明即可.

另外想说

ai就像什么都特懂且对于什么都很有经验, 所以需要你在关键点尽可能描述好你的需求防止他按照"一般做法"实现而忽视了你的项目现状和你的实际需求.

以往我学编程技术都是在哔站跟着视频一期一期从入门->上手->学api->实战->实际开发走这个流程, 这样子学效率暂且不说, 最挫败学习热情是最主要的. 我就以我试图开发一个"记录每日完成设定任务的工具"这一几乎在不能更简单的想法的实现过程开始现身说法吧, 下面是我痛苦又成效甚微的路线:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
flowchart TD
	A[学C语言] -- 一个多月每天挺高强度还认真记笔记地学, 但是发现不适合做图形应用 --> B{学Qt开发}
	B -- C++写ui布局和样式真的很蛋疼. --> F[引入QSS]
	C[学习前端三大件] --> F
	F -- 只是封装页面跳转就让我小一周都实现不了 -->E[Qt with Python]
	B -- 想看看基于python会不会简单点 --> D[PyQt]
	C-->H[Python的Flask框架实现http服务器]
	F -- 苦恼于前端三大件这么方便但是只能做前端, 直到... -->G[学习NodeJs]
	subgraph 网页应用 #仅在电脑端可用.
		H[Python的Flask做后端实现功能, 第一版]
		G[学习NodeJs, 原生前端+nodejs实现功能, 第二版]
		G -- 使用jQuery重构原生前端 -->I[第三版网页应用]
		I--学习Vue框架, 第二次重构前端以及部分后端-->J[第四版网页应用]
		K(想使手机可操作, 学习微信小程序开发.)-- 花了段时间稍微入门-->L[发现不适合我的场景, 时间试错成本蛮高]
	end
	J--数据迁移-->R
	S[学mysql]-->T[postgresql]
	subgraph 移动应用 #离线使用+自用服务器
		M[学习Flutter] -- 5个月学习,自主开发+1个月AI助力-->O(当前torrid应用)
		N[学习Go] --高性能, 低占用-->P[http服务器'monarch']
		N--高效的CLI程序-->Q[monarch_gizmos]
		T
		O & P & Q & T --联动-->R[业务闭环的自用软件/服务]
	end

如果页面无法渲染mermaid:

假设按照以往我学编程技术那样在哔站跟着视频一期一期从入门->上手->学api->实战->实际开发走这个流程, 即使分身出四五个我自己估计我都无法做出现有的这些东西.

管他的写到一半先发了吧, 年后再细说…

使用 Hugo 构建
主题 StackJimmy 设计