# Enums: typed closed sets for JSON, TCP, and SQL Closed type/status values live in `internal/module/{biz}/entity` as named types. OpenAPI names the same set once and `$ref`s it. Do not invent a parallel string vocabulary. `GET /v1/meta/enums` is the client bootstrap (no auth). Human pages: `/docs/enums` (jumpable sets) and `/docs/errors` (`envelope_code`). `value` is the wire number or string; `name` is a stable English token; `label` is i18n for the request language. Values are built from entity consts — do not hand-copy numbers into the handler. Wire JSON and TCP stay **numbers** (or the existing strings below). Do not turn message types into strings. Do not change persisted numeric values. ## Template (`message/entity.Type`) ```go type Type int16 const ( TypeText Type = 1 // ... TypeSystem Type = 9 ) func (t Type) Valid() bool { return t >= TypeText && t <= TypeSystem } ``` Input DTOs that bind the field directly use the same numbers in `validate:"oneof=..."`: ```go Gender *entity.Gender `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"` ``` Nested JSON (e.g. `content.t`) is not a validator field; gate it with `Valid()` inside `UnmarshalContent` / `Content.Validate`. ## Layers | Layer | Rule | |-------|------| | entity | `type X int` / `int16` / `string` + const + `Valid()`. Domain structs use the typed field. | | model | GORM rows stay `int` / `int16` / `string`. Convert at `entity()` / `rowFrom`. Never store a Go typed enum in a GORM struct if the column is numeric. | | logic JSON | Response/request DTOs use the entity type so encoding/json emits the number (or the string). | | HTTP validator | `oneof` lists the **same** numbers or strings as the consts. i18n `errmsg` for `oneof`. | | OpenAPI | One named schema in `components/schemas`; fields `$ref` it. Do not inline `enum:` on the field. | | HTTP catalog | Add the set to `meta/logic.Catalog` and i18n `enum.{set}.{name}`. Served by `GET /v1/meta/enums`. | Name clashes: the JSON viewer `relation` is `RelationKind`; the table column is `EdgeStatus`. Do not name both `Status` in the same package if two tables differ. ## Numeric catalog Values are stable. Do not reuse a retired number. | OpenAPI schema | Go | JSON / TCP | SQL | Values | |----------------|----|------------|-----|--------| | `Gender` | `user/entity.Gender` | `gender` | `users.gender` | 0未知 1男 2女 | | `UserStatus` | `user/entity.Status` | `status` | `users.status` | 0停用 1正常 | | `ConversationType` | `conversation/entity.Type` | (internal / conv type) | `conversations.type` | 1单聊 2群聊 | | `MessageKind` | `message/entity.Kind` | `kind` | not a column; selects `private_message` vs `group_message` | 1 / 2, **same numbers as ConversationType** | | `MessageType` | `message/entity.Type` | `message_type`, `content.t` | `*_message.message_type` | 1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统 | | `MessageStatus` | `message/entity.Status` | `status` | `*_message.status` | 1正常 2已撤回 3审核隐藏 | | `RelationKind` | `friend/entity.RelationKind` | `relation` | not a column (viewer-centric) | 0无 1好友 2已拉黑 | | — | `friend/entity.EdgeStatus` | not on the wire | `friend_relations.status` | 0非好友 1好友 | | `FriendRequestStatus` | `friend/entity.RequestStatus` | apply `status` | `friend_requests.status` | 1待处理 2已同意 3已拒绝 | | `FriendSource` | `friend/entity.Source` | `source` | request + edge `source` | 0未知 1二维码 2邮箱 3spark_id | | `ReportReason` | `friend/entity.ReportReason` | `reason` | `friend_reports.reason` | 1垃圾 2骚扰 3不实身份 4其他 | | `GroupStatus` | `group/entity.Status` | (internal) | `groups.status` | 1正常 2已解散 | | `GroupRole` | `group/entity.Role` | `role` | `group_members.role` | 1群主 2管理员 3成员。PATCH 只能 2 或 3 | | `MailboxEventType` | `sync/entity.EventType` | mailbox `event_type` | `mailbox_events.event_type` | 1漏推 2撤回 3删除 | | `TcpCommand` | `tcp/protocol.Cmd*` | Frame.Command | — | 1–15 see protocol | | `EnvelopeCode` | `pkg/errs` codes | envelope `code` | — | `"0"` + `40001`… | `POST /v1/friends/requests` returns `FriendApplyData.status` as **1 or 2**, not `"pending"` / `"accepted"`. Compare `MessageKind` with `conversation.entity.Type`, never with `message.entity.Type`. ## String catalog (keep as strings) Do not convert these to integers. | OpenAPI schema | Go | JSON | Values | |----------------|----|------|--------| | `DeviceType` | login DTO `string` + `oneof` | `device_type` / `platform` | `ios android harmony windows macos web` | | `Language` | `i18n.Lang` | `language`, `Accept-Language`, `X-Language` | `zh-CN` `en` `zh-TW` (default `zh-CN`) | | `UploadPurpose` | `upload/entity.Purpose*` consts + `LookupSpec` | `purpose` | `avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file` | | `SystemKind` | `friend/entity.SystemKind*` / `group/entity.SystemKind*` | `content.system.kind` | `friend.became` `group.created` `group.invite` `group.leave` `group.kick` `group.dissolve` `group.transfer` | Health `status` (`ok` / `not_ready`) is process liveness, not a domain enum. ## Adding a closed set 1. Put `type X int` (or keep a string const set) in the owning module `entity`. Add `Valid()`. 2. Use `X` on entity + JSON DTOs. Convert at the GORM boundary. 3. Bind request fields with `oneof=` listing the same values; add i18n `errmsg`. 4. Add a named schema in `internal/transport/http/docs/openapi.yaml` and `$ref` every field. 5. Add the set to `GET /v1/meta/enums` (`meta/logic`) and i18n labels `enum.{set}.{name}` (zh-CN / en / zh-TW). 6. Update this catalog. Do **not**: change existing numbers; stringify a numeric set; duplicate enum lists in handlers.