- 后端
- 前端
- 图像处理
- 人工智能
- AI 应用
【免费下载链接】photoprism
AI-Powered Photos App 🌈💎✨
本指南以仓库.claude/rules/api-and-config.md为骨架,系统讲解 Photoprism 后端开发中「API 与配置变更」必须遵循的工程规范,覆盖配置优先级、新增选项的标准流程、Handler 约定、API 字段形状、会话与认证缓存、测试助手以及角色与 ACL 映射。读者将掌握如何在不破坏既有安装的前提下新增配置项、如何写出安全且可维护的 HTTP Handler,以及如何正确复用项目内置的认证、限流与持久化基础设施,可直接用于日常的 Go 后端开发与代码评审。
配置优先级:options.yml高于 CLI 与环境变量
Photoprism 的配置体系遵循一条铁律:options.yml中的值覆盖 CLI/环境变量提供的值,而 CLI/环境变量又覆盖内置默认值(options.yml overrides CLI/env values, which override defaults)。理解这条优先级链是进行任何配置改动的前提——同一选项在三处出现时,最终生效的永远是以options.yml持久化的值。
从源码看,这一设计贯穿于配置的读取与回写:internal/config/config.go中的Options()返回原始配置选项,SaveOptionsPatch()则负责把补丁合并进options.yml并重载内存选项(internal/config/config.go#L543-L580):
// Options returns the raw config options. func (c *Config) Options() *Options { ... } // SaveOptionsPatch merges a patch into options.yml, reloads in-memory options, // and returns true when persisted values changed. func (c *Config) SaveOptionsPatch(patch Values) (bool, error) { if err := CoerceOptionValues(patch); err != nil { return false, err } fileName, values, err := c.loadOptionsYAML() if err != nil { return false, err } if !mergeOptionValues(values, patch) { return false, nil } if _, err = c.writeOptionsYAML(fileName, values); err != nil { return true, err } return true, c.applyOptionValues(patch) }注意SaveOptionsPatch在写入前会先通过CoerceOptionValues对补丁值做类型校验,确保「文件里的值与运行中的配置不会出现数字不一致」。与之配套的还有DeleteOptionsPatch(keys ...):删除键会恢复默认值,而写入空值不会——因为加载器无法区分「被清空的选项」和「被设置为空的选项」。
对于集群托管场景,internal/config/config_cluster.go#L39-L65提供了SaveClusterOptionsUpdate(update cluster.OptionsUpdate),它将集群下发的ClusterUUID、NodeClientID、JWKSUrl、DatabaseDSN等字段组装成 patch 后复用SaveOptionsPatch落盘,并在写入前通过validateClusterOptionsUpdate校验 UUID 合法性。
新增一个选项的标准流程
在 Photoprism 中新增配置选项不是「加一个字段」那么简单,必须走完完整的注册链路:
- 定义 yaml/flag 标签:在
internal/config/options.go中为新选项添加带yaml/flagtag 的字段; - 注册 CLI flag:在
internal/config/flags.go的全局Flags列表中注册对应 flag(该文件共 1500 余行,定义了全部 CLI 参数,例如auth-mode、admin-user、oidc-*系列,均通过cli.StringFlag/cli.BoolFlag与EnvVars(...)绑定环境变量); - 暴露 getter:在
*config.Config上提供公开访问器(如JWKSUrl()/SetJWKSUrl(),见internal/config/config_cluster.go#L656与#L686),并优先使用这些公开访问器而非直接改动Config.Options()——直接改动裸选项被保留为测试 fixture 专用手段; - 写入报告:将新选项接入
*config.Report(),使配置导出时可见; - 回写
options.yml:确保生成值能持久化回配置文件; - 测试:在
internal/config/test.go中使用CliTestContext演练新 flag 的解析行为。
DocDefault:让--help与文档引用保持一致
这是 Photoprism 配置体系中一个非常精巧的机制。internal/config/flags.go的init()会调用Flags.ApplyDocDefaults(),其实现位于internal/config/cli_flag.go#L37-L52:遍历所有 flag,凡是设置了DocDefault且其DefaultText为空时,就把DocDefault复制进DefaultText,从而让--help打印出文档中承诺的默认值。
为什么需要它?cli_flag.go的注释解释得很清楚:一个 getter 把0读作「运行时自动推导」的选项,如果没有DocDefault,--help会展示(default: 0),这会被误读为「生效值就是 0」而不是「缺省」。因此规范要求:
- 若默认值在运行时才解析(如人脸阈值
face-score、聚类距离face-cluster-dist),应使用DocDefault命名真正生效的数字,而不是detector之类的单词,也不要用会改变零值语义的Value:; - 若默认值本身就是常量(如
face-size、face-overlap、session-maxage),设置Value:依然是正确的做法。
internal/config/flags.go中大量使用了DocDefault,例如face.DefaultDetectorName()、faceDocDefault(face.DefaultDetectorScore(...))、faceModelDocDefault(...)等,都是把源码常量格式化为文档默认值。
CliFlag的Default()方法(internal/config/cli_flag.go#L59-L71)还有一个细节:Secret flag 一律折叠为DocDefault(或空串),保证生成的文档与配置报告跨部署保持稳定,不泄露敏感默认值。
Usage 字符串的三个硬性要求
- 禁止插值运行时可变包级变量:
Flags在包初始化期构建,若把Config.Propagate之后才会重新赋值的变量(如ttl.DownloadToken)插进Usage,字符串会冻结在初始化时的值并静默漂移。必须改为插值const(如ttl.DownloadTokenDefaultAge、ttl.DownloadTokenMinAge),让帮助文本声明的边界与真正强制执行的边界永不背离; - 保持紧凑:Usage 在长
--help列表中逐行展示,冗长文案会破坏可读性; - 说明启用后的操作后果:不要只描述「设置了什么」,而要写清楚「开启后会发生什么」(例如
publicflag 的文案是 "disables authentication, advanced settings, and WebDAV remote access"——直接说明后果而非设置本身)。
新增customize.FeatureSettings开关:反射默认 + 环境变量禁用
如果你要新增一个前端功能开关(而非 CLI 选项),Photoprism 提供了一条低成本路径:
- 在
customize.FeatureSettings中新增字段,默认值通过反射机制在internal/config/customize/features_default.go中统一置为true:// initDefaultFeatures builds the package-level defaults and applies any disable // list supplied via PHOTOPRISM_DISABLE_FEATURES. func initDefaultFeatures() FeatureSettings { features := FeatureSettings{} disabled := buildDisabledSet(os.Getenv("PHOTOPRISM_DISABLE_FEATURES")) val := reflect.ValueOf(&features).Elem() typ := val.Type() for i := 0; i < typ.NumField(); i++ { if len(disabled) > 0 { candidates := []string{ clean.FieldNameLower(field.Tag.Get("json")), clean.FieldNameLower(field.Name), } if isDisabled(disabled, candidates) { continue } } val.Field(i).SetBool(true) } return features }这意味着无需新增 CLI 选项,运维即可通过
PHOTOPRISM_DISABLE_FEATURES(逗号/空格分隔的功能名列表)在启动时禁用任意开关; - 级联更新:由于字段名会参与对齐,需要同步更新
internal/config/customize/acl_test.go、scope_test.go和internal/config/client_config_test.go中的全结构字面量(最长字段名会让 gofmt 重新对齐所有字面量);testdata/settings.yml会通过TestSettings_Save自动自我更新; - 按会话门控:若开关仅对特定账户/角色有意义,应在
customize.Settings.ApplyACL/ApplyScope中按会话判断(例如Account/AppPasswords需要ResourcePassword/ActionUpdate权限),这只会影响 Web UI 客户端配置的形状;服务端行为的强制则由全局 flag 配合Config.DisableX()helper 完成——internal/config/config_features.go中提供了DisableFrontend()、DisableSettings()、DisableRestart()、DisableWebDAV()、DisableAppPasswords()、DisableMCP()、DisablePlaces()、DisableFaces()、DisableFFmpeg()等一系列方法。
识别 App Password:按 Session 而非 Token
识别一个凭据是否为「应用密码(app password)」时,必须通过(*entity.Session).IsApplication()判定,而不能检查 token 格式或授权类型。原因在internal/entity/auth_session.go#L444-L450有直接注释:
// IsApplication checks whether this session has been authenticated using an app password. // The application provider is set only for user-bound app passwords, regardless of the grant // type that minted them (password for local users, session for OIDC-only users, cli for the // "auth add" command), so the provider alone identifies an app password. func (m *Session) IsApplication() bool { return authn.Provider(m.AuthProvider).IsApplication() }三种 grant 类型(password/session/cli)都会铸造应用密码,且 token 格式各不相同,因此基于rnd.IsAppPassword或GrantType做判断都是不可靠的——只有 auth provider 为application才能稳定标识。
options.yml的写入与文件名约定
- 持久化写回:优先使用配置自有的持久化助手——通用合并用
Config.SaveOptionsPatch(...),集群托管元数据用Config.SaveClusterOptionsUpdate(...),删除用DeleteOptionsPatch(...),而不是自己直接操作 YAML 文件; - 文件名:统一使用
pkg/fs.ConfigFilePath生成配置文件名,这样既能让既有.yml文件继续有效,又能让新安装透明地采用.yaml扩展名。
元数据源与配置初始化顺序
- 新增元数据源:例如
SrcOllama、SrcOpenAI,必须同时在两处定义:后端internal/entity/src.go(SrcMap中登记,源码中已有SrcAuto、SrcFile、SrcYaml、SrcOIDC、SrcLDAP等带优先级编号的源)以及前端查找表frontend/src/common/util.js,否则前后端对数据源的理解会分裂; - 配置初始化顺序(写扩展时的关键时序):
- 加载
settings.yml(调用c.initSettings()); - 运行
Ext(StageBoot).Boot(c); - 连接/注册数据库;
- 运行
Ext(StageInit).Init(c)。 引导阶段扩展用config.Register(config.StageBoot, ...)注册,避免在数据库尚未就绪时执行依赖 DB 的逻辑;
- 加载
- CLI flag 优先:在覆盖用户提供的值之前,先检查
c.cliCtx.IsSet("<flag>"),尊重用户显式指定的参数; - 数据库助手:复用
conf.Db()/conf.Database*()(实现在internal/config/config_db.go,包括DatabaseDriver()、DatabaseHost()、DatabaseName()、Db()等),避免直接使用 GORMWithContext;MySQL 标识符需要加引号转义,并在早期就拒绝不支持的驱动。
Handler 约定:限流、请求体上限与 413
复用限流栈
HTTP Handler 层应复用现有限流栈(limiter.Auth、limiter.Login),用limiter.AbortJSON处理 429 响应,并依赖api.ClientIP、header.BearerToken与Abort*系列助手,而不是自己重复实现限流与错误返回逻辑。
请求体大小限制:每个 Handler 自己负责
Photoprism没有全局的请求体限制中间件,LimitRequestBodyBytes(c, Max<Domain>RequestBytes)必须由每个 Handler 在读取任何内容之前显式调用——因为表单解析(c.PostForm、ParseForm)读取 body 的方式与ShouldBind完全一致,不提前限制就会让超大 payload 直接进入解析。实现在internal/api/request_limits.go:
// LimitRequestBodyBytes caps the readable request body size for the current handler. func LimitRequestBodyBytes(c *gin.Context, limit int64) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) } // IsRequestBodyTooLarge reports whether the parsing error was caused by a body-size limit. func IsRequestBodyTooLarge(err error) bool { var maxBytesErr *http.MaxBytesError return errors.As(err, &maxBytesErr) || errors.Is(err, multipart.ErrMessageTooLarge) }同文件定义了各领域上限常量:MaxAuthRequestBytes(64 KB,认证与凭据变更)、MaxMutationRequestBytes(256 KB,通用 JSON 变更)、MaxSelectionRequestBytes(1 MB,批量选择类变更)、MaxVisionRequestBytes(32 MB,Vision API 需容纳 data URL)、MaxWebDAVMetadataRequestBytes(128 KB,WebDAV XML 会被完整解析进内存并可能被 LOCK owner 保留)、MaxMCPRequestBytes(MCP JSON-RPC,上游 SDK 会用io.ReadAll读满 body,因此必须在 Handler 边界拦截)等。实际用法可见internal/api/session_create.go#L45的LimitRequestBodyBytes(c, MaxSessionRequestBytes)。
配套规范:
- 错误分支用
IsRequestBodyTooLarge(err)判断后返回AbortRequestTooLarge(413); - 必须把
413加入该 Handler 的 Swagger@Failure列表——make check-api-failure-codes(属于make lint)会报告「Swagger 已文档化、能返回 413 却未声明」的 Handler; - 未认证端点在读取 body 之前就要计费限流器,否则一个永远到不了凭据检查的请求可以无限次免费重放。
其他约定
- 敏感信息比较使用常量时间比较;敏感响应设置
Cache-Control: no-store; - 新路由统一注册在
internal/server/routes.go;新的列表端点默认count=100(上限 1000)、offset≥0,且参数必须显式文档化; - Portal 模式通过
PHOTOPRISM_NODE_ROLE=portal设置,必要时配合PHOTOPRISM_JOIN_TOKEN(相关默认值见internal/config/config_cluster.go:DefaultPortalUrl、DefaultNodeRole = cluster.RoleInstance、DefaultJWTAllowedScopes = "config cluster vision metrics mcp users")。
API 字段形状清单(Shape Checklist)
重命名或新增字段时,按以下清单逐项核对:
- 字段大小写:
- 由数据库实体支撑的字段用TitleCase(
UUID、Name、SiteUrl),与实体/模型镜像; - 生成/人工构造的 payload(客户端配置、会话、action/RPC 请求体)用camelCase(
storageNamespace、redirectUri); - 被过滤/计算的实体投影保持 TitleCase;action payload 保持 camelCase,但可以对唯一的实体标识字段使用 TitleCase(如
UUID)。完整规则见specs/common/field-casing.md;
- 由数据库实体支撑的字段用TitleCase(
- 更新 DTO:同步更新
internal/service/cluster/response.go及所有 mapper; - 更新 Handler 并重新生成 Swagger:运行
make fmt-go swag-fmt swag; - 更新测试与示例:全局替换旧字段名,并更新
specs/下的示例; - 快速查漏:跨代码、测试与 specs 运行
rg -n 'oldField|newField' -S。
会话与认证缓存(Session & Auth Caches)
WebDAV 认证使用实体层缓存助手,遵循以下要点(实现在internal/entity/auth_session_cache.go、auth_user_cache.go、auth_cache_generation.go):
- 账户更新:通过
User.Save的账户修改只失效该用户自己的会话与 WebDAV 缓存——FlushUserSessionCache(userUID)(internal/entity/auth_session_cache.go#L76),而不是全局清空; - 代数(generation)捕获时机:必须在认证查找之前捕获
CurrentAuthCacheGeneration()并传给CacheWebDAVUser(key, user, generation);绝不能在插入时才捕获,也绝不能在一个更旧的 session 对象上刷新代数。FindSession(auth_session_cache.go#L25-L60)正是先取generation := CurrentAuthCacheGeneration(),缓存命中则直接返回,未命中才回源数据库并以带代数的对象写入缓存; - 缓存与吊销分离:缓存驱逐与持久化凭据吊销各自独立;进程内(process-local)语义必须保留——即缓存只在本进程生效,不能被误当作跨进程的权威状态。
测试助手(Testing Helpers)
- 隔离配置路径:使用
t.TempDir(),复用NewConfig、CliTestContext、NewApiTest()测试框架; - 认证:通过
AuthenticateAdmin、AuthenticateUser或OAuthToken建立会话;用conf.SetAuthMode(config.AuthModePasswd)切换认证模式; - 负面权限断言:优先使用 OAuth 客户端 token 而非非管理员 fixture,避免 fixture 维护成本。
角色与 ACL:共享映射表与版本差异
- 角色映射:用户通过
acl.ParseRole(s)/acl.UserRoles[...],客户端通过acl.ClientRoles[...],统一走共享表,禁止各自维护副本; - 空值与别名:
RoleAliasNone("none")与空字符串一律视为RoleNone;未知客户端角色默认归为RoleClient; - CLI 角色帮助:必须从已注册的角色映射构建(绝不手写字面量),这样每个版本列出的恰好是它接受的角色:
commands.UserRoleUsageFor(<map>)/Roles.CliUsageString()。传入 CE 的acl.UserRoles或某版本自己的静态auth.UserRoles时,要直接引用版本映射本身,而不是运行时被重新赋值的acl.UserRoles,以避免初始化顺序陷阱(Portal 的映射额外包含cluster_admin);对于可联邦/集群实例上下文(LDAP、OIDC 组→角色、集群授权),使用acl.ClusterInstanceRolesCliUsageString()——它排除了cluster_admin与visitor;pkg/txt.JoinOr用于渲染 "a, b, or c" 风格的并列文案; - JWT/客户端 scope 检查:使用共享助手
acl.ScopePermits/acl.ScopeAttrPermits,不要另起炉灶。
结语
Photoprism 的 API 与配置体系并非随意生长,而是一套高度自洽的工程约束:配置优先级、DocDefault机制、按会话门控的 FeatureSettings、Handler 级请求体限制、代数化的认证缓存,以及统一角色映射表,共同保证了多版本(CE/Plus/Pro/Portal)与集群部署下行为的一致性。遵循本指南,可以让你的改动与既有生态无缝衔接,也让他人 review 时能快速确认你的实现符合项目长期演进的方向。若需深入某个机制的细节,建议直接阅读本指南引用的源码文件:internal/config/flags.go、internal/config/cli_flag.go、internal/api/request_limits.go、internal/entity/auth_session_cache.go与internal/config/customize/features_default.go。
- 后端
- 前端
- 图像处理
- 人工智能
- AI 应用
【免费下载链接】photoprism
AI-Powered Photos App 🌈💎✨
相关推荐
new-api 前端开发规范实战指南:React 19 + TypeScript 工程化与协作守则
new api 前端开发规范实战指南:React 19 + TypeScript 工程化与协作守则 导读 new api 是一个 Go 编写的 AI API 聚
后端API网关LLM 网关大模型认证鉴权桌面应用前端开发者工程规范实践指南:Commitizen与CHANGELOG的完整配置教程
前端开发者工程规范实践指南:Commitizen与CHANGELOG的完整配置教程 在现代前端开发中,规范的代码提交信息和自动生成的变更日志是团队协作和项目维护
教程前端easy-vibe 产品思维与方案设计:从想法验证、双钻拆解到 AI 放大价值的实战方法
easy vibe 产品思维与方案设计:从想法验证、双钻拆解到 AI 放大价值的实战方法 本文基于 easy vibe 课程(Vibe Coding 101,面
后端前端图像处理人工智能AI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考