diff --git a/code/app/CHAT_MODULE.md b/code/app/CHAT_MODULE.md new file mode 100644 index 00000000..01281eec --- /dev/null +++ b/code/app/CHAT_MODULE.md @@ -0,0 +1,245 @@ +# 家庭聊天模块 + +## 概述 + +一个家庭一个聊天室,成员通过 WebSocket 实时收发消息。支持模块内容转发、图片/视频/文件、消息撤回与删除。 + +--- + +## 数据库表 + +### b_chat — 聊天消息 + +```sql +CREATE TABLE b_chat ( + id BIGINT PRIMARY KEY, + family_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + content TEXT NOT NULL DEFAULT '', + type SMALLINT NOT NULL DEFAULT 0, -- 0=text 1=image 2=video 3=file 4=source + source_table VARCHAR(64), + source_id BIGINT, + source_data TEXT, -- 转发时存的快照 JSON + recalled SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=撤回 + deleted SMALLINT NOT NULL DEFAULT 0, -- 0=否 1=管理员删除 + create_time TIMESTAMPTZ NOT NULL, + update_time TIMESTAMPTZ NOT NULL, + create_by BIGINT NOT NULL, + update_by BIGINT NOT NULL +); +``` + +**为什么不存 nickname / avatar** + +消息只存 `user_id`,昵称和头像通过 `b_family_member` JOIN 实时取。这样用户更新资料后,所有历史消息自动显示最新的昵称和头像。如果成员已退出家庭(JOIN 不到),前端显示"已退出的成员"兜底。 + +**字段说明** + +- type: `0` 文本 / `1` 图片 / `2` 视频 / `3` 文件 / `4` 转发(所有子模块均可转发,服务端校验 `source_table` 是否存在即可) +- recalled: 发送者 **2 分钟内**可撤回,全员看到"xxx 撤回了一条消息" +- deleted: `b_family_member.role = 0`(管理员)可删除任意消息,全员不可见 + +### b_file — 通用附件 + +```sql +CREATE TABLE b_file ( + id BIGINT PRIMARY KEY, + target_id BIGINT NOT NULL, + file_key VARCHAR(256) NOT NULL, + file_name VARCHAR(256), + file_type SMALLINT NOT NULL, -- 1=image 2=video 3=file + mime_type VARCHAR(64), + file_size BIGINT, + width INT, + height INT, + duration INT, + sort_order INT NOT NULL DEFAULT 0, + create_time TIMESTAMPTZ NOT NULL, + update_time TIMESTAMPTZ NOT NULL, + create_by BIGINT NOT NULL, + update_by BIGINT NOT NULL +); +``` + +`target_id` 关联到任意表的记录(雪花 ID 全局唯一),chat / note / task 等模块共用。 + +### 存量表改动 + +```sql +ALTER TABLE b_family_member ADD COLUMN last_read_id BIGINT DEFAULT 0; +``` + +--- + +## 后端实现 + +### 文件结构 + +``` +app-go/internal/ +├── ws/ +│ ├── hub.go -- Hub + Room(map[family_id]*Room) +│ ├── client.go -- 单连接读写 goroutine、心跳、断连处理 +│ └── handler.go -- HTTP → WS 升级、鉴权、消息路由 +└── router/ + └── router.go -- 新增 GET /app/chat/ws +``` + +依赖:`github.com/gofiber/contrib/v3/websocket`(Fiber v3 官方 WS 支持) + +### WebSocket 协议 + +连接:`GET /app/chat/ws?token=xxx&family_id=123` + +鉴权独立实现(不复用 `middleware.Auth()`,WS 无法设自定义 header):token 解 user_id → family_id 校验归属 → 加入 Room。 + +客户端消息: + +```jsonc +// 发送文本 +{"type": "send", "content": "晚上吃啥"} + +// 发送图片/视频/文件(先 S3 上传拿 key,再发消息) +{"type": "send", "content": "", "msg_type": 1, "attachments": [ + {"file_key": "2026/06/25/uuid.jpg", "file_name": "photo.jpg", "file_type": 1, "mime_type": "image/jpeg", "file_size": 204800, "width": 1080, "height": 720} +]} + +// 转发 +{"type": "send", "content": "看看这笔", "msg_type": 4, "source_table": "b_finance", "source_id": 456} + +// 撤回 +{"type": "recall", "message_id": 123} + +// 删除(管理员) +{"type": "delete", "message_id": 123} +``` + +服务端广播: + +```jsonc +// 新消息 +{"type": "message", "data": { + "id": 123, "family_id": 1, "user_id": 1, + "nickname": "小明", "avatar": "https://...", // 从 b_family_member JOIN + "content": "晚上吃啥", "msg_type": 0, + "attachments": null, + "source_table": null, "source_id": null, "source_data": null, + "create_time": "2026-06-25 21:30:00" +}} + +// 撤回通知(全员) +{"type": "message_recalled", "message_id": 123} + +// 删除通知(全员) +{"type": "message_deleted", "message_id": 123} + +// 在线成员(全员广播,全量替换式) +{"type": "online", "users": [{"user_id": 1, "nickname": "小明", "avatar": "https://..."}]} + +// 成员下线(单条) +{"type": "offline", "user_id": 3} +``` + +### 消息处理流程 + +``` +客户端 WS 消息 + → 解析 type 字段路由: + + send: + 1. 从连接上下文取 family_id / user_id + 2. getUniqueId 拿消息 ID + 3. 若 msg_type = 4(转发):查 source_table WHERE id = source_id,整行转 JSON 存 source_data + 4. 若 attachments 非空:批量 INSERT b_file(target_id = 消息 ID) + 5. INSERT b_chat + 6. JOIN b_family_member 取 nickname / avatar + 7. 组装 message 广播到 Room + + recall: + 1. 查 b_chat WHERE id = message_id + 2. 校验 user_id == 当前用户 && now - create_time <= 2 分钟 + 3. UPDATE b_chat SET recalled = 1 + 4. 广播 message_recalled + + delete: + 1. 查 b_family_member WHERE family_id = ? AND user_id = 当前用户 + 2. 校验 role = 0 + 3. UPDATE b_chat SET deleted = 1 + 4. 广播 message_deleted +``` + +在线状态:WS 连上时广播 `online`(全量),断开时广播 `offline`(单条)。 + +--- + +## 前端实现 + +### 文件结构 + +``` +app-rn/src/ +├── app/(main)/(tabs)/chat/ +│ └── index.tsx -- 聊天室 +├── app/(main)/chat/ +│ └── history.tsx -- 历史消息搜索 +├── components/chat/ +│ ├── MessageList.tsx -- FlatList 倒序渲染 +│ ├── TextBubble.tsx -- 文本气泡 +│ ├── ImageBubble.tsx -- 图片气泡 +│ ├── VideoBubble.tsx -- 视频气泡 +│ ├── FileBubble.tsx -- 文件气泡 +│ ├── ForwardCard.tsx -- 转发卡片(source_data 渲染) +│ ├── InputBar.tsx -- 输入框 + 附件按钮 +│ └── OnlineBar.tsx -- 在线成员头像行 +└── hooks/ + └── useChatWebSocket.ts -- WS 连接、自动重连、消息去重 +``` + +### 聊天页面数据流 + +``` +进入页面 + → loadDataPageApi("b_chat", "family_id = ? AND deleted = 0", "create_time DESC", [familyId]) + → 取 b_family_member 获取成员昵称/头像 Map(发消息和渲染时用) + → 若 msg_type IN (1,2,3):批量查 b_file WHERE target_id IN (...) + → 连接 WS + +收到 WS message + → id 去重 + → 追加到 FlatList 底部 + → 更新 last_read_id + +发送消息 + → 文本:直接 WS send + → 图片/视频/文件:先 s3UploadFileApi → 拿 key → WS send + → 转发:子模块调 WS send 带 source_table + source_id + → 乐观更新(灰色气泡 → 成功实色 / 失败红色+重试) + +撤回 + → WS send recall + → 收到 message_recalled → 对应气泡替换为提示 + +删除(管理员可见删除按钮) + → WS send delete + → 收到 message_deleted → 移除该消息 +``` + +依赖:React Native 内置 [`WebSocket`](https://reactnative.dev/docs/network#websocket),无需额外安装。重连逻辑在 `useChatWebSocket` hook 里封装。 + +### WS 连接管理 + +- 断线自动重连(exponential backoff:1s → 2s → 4s → … → 上限 30s) +- 重连后用 `loadDataPageApi` 拉 `WHERE id > lastReceivedId` 补洞 +- `online` 事件更新 OnlineBar,`offline` 移除对应头像 + +### 转发卡片渲染 + +`JSON.parse(source_data)` 展示关键字段,点击跳转对应模块详情页。源记录删除后卡片仍可展示(快照自包含)。 + +### 历史消息搜索 + +独立页面 `chat/history.tsx`,走 `loadDataPageApi` + `loadDataBySqlApi`: +- 关键词搜索:`WHERE content ILIKE '%keyword%'` +- 按 msg_type 筛选 +- 按日期范围筛选 +- 点击消息跳回聊天室定位 diff --git a/code/app/CLAUDE.md b/code/app/CLAUDE.md new file mode 100644 index 00000000..91ee0a79 --- /dev/null +++ b/code/app/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Allapp is a family/space mini-program platform — a main shell with sub-apps inside it. The frontend owns most business logic; the backend is deliberately thin and generic (auth, file storage, ID generation, database access). + +- **Backend**: `app-go/` — Go 1.25, Fiber v3.1, pgx (PostgreSQL), JWT, Viper, Zap +- **Frontend**: `app-rn/` — React Native 0.85, Expo 56, Expo Router, Zustand, Alova, HeroUI Native, Uniwind (Tailwind CSS) +- **Database**: PostgreSQL 18.1, database `allapp`, schema `public` + +Read `PROJECT_CONTEXT.md` for deeper context; this file focuses on what you need day-to-day. + +## Commands + +### Backend (`app-go/`) + +```bash +cd app-go +go build ./cmd/app # Build +go run ./cmd/app # Run server +go test ./... # Run all tests +go mod tidy # Tidy dependencies +``` + +### Frontend (`app-rn/`) + +```bash +cd app-rn +pnpm start # Expo dev server (port 8083) +pnpm android # Android build +pnpm ios # iOS build +pnpm lint # oxlint +pnpm exec tsc --noEmit # TypeScript type check +pnpm exec oxfmt # Format code +``` + +WSL-specific: if `node`/`pnpm` are not found, run: +```bash +export PATH="$HOME/.local/share/fnm:$PATH" +eval "$(fnm env --shell bash)" +``` + +## Architecture + +### Bootstrap Flow + +``` +Backend: config → logger → S3 → Postgres → db.Init → unique ID → JWT → WeChat → Fiber server +Frontend: /index → token check → auth/login or family/entry or /home +``` + +### Backend Design + +**All CRUD goes through 5 generic data APIs.** Never create dedicated REST endpoints for feature-specific operations: + +| Endpoint | Purpose | +|---|---| +| `POST /app/data/loadData` | Query by table/view name, conditions, columns, order, args | +| `POST /app/data/loadDataPage` | Paginated query — same as loadData + `page`/`page_size`, returns `{rows, total, page, page_size}` | +| `POST /app/data/loadDataBySql` | Raw SQL query | +| `POST /app/data/saveData` | Batch insert/update/delete by table name and key field | +| `POST /app/data/getUniqueId` | Get snowflake IDs (1–100) | + +Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`. + +**File endpoints:** + +| Endpoint | Purpose | +|---|---| +| `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys | +| `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success | + +On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. + +Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`. + +### Frontend Design + +Expo Router file-based routing under `src/app/`: +- `(main)/(tabs)/` — main shell tabs (home, chat, my) +- `(main)/auth/`, `(main)/family/`, `(main)/tools/`, `(main)/activities/` +- `finance/(tabs)/` — finance sub-app (independent from main) +- Layouts: `AppLayout` for main shell, `ModuleLayout` for sub-app shells, `PageLayout` for simple pages + +State: Zustand with `persist` middleware for `user` and `family` (AsyncStorage). Finance store is in-memory only. + +API layer: single Alova instance (`src/request/index.ts`), API wrappers in `src/request/api.ts`. Key wrappers: `loadDataApi`, `loadDataPageApi` (paginated, returns `PageData` with `rows`/`total`/`page`/`page_size`), `loadDataBySqlApi`, `saveDataApi`, `getUniqueIdApi`. Response codes: 1000 (success), 1001 (unauthorized → redirect to login), 2000 (business error), 3000 (system error). + +Startup flow in `src/app/index.tsx`: +``` +no token → /auth/login +token invalid → /auth/login +no family → /family/entry +has family → /home +``` + +## Critical Conventions + +### 1. No New Dedicated API Endpoints +Always reuse the 5 generic data APIs. If you think a new endpoint is needed, **ask the user first**. Even then, new endpoints must be generic helpers, not feature-specific. + +### 2. Database Tables Must Include Audit Columns +All new business tables must include: `id BIGINT PRIMARY KEY` (snowflake), `create_time TIMESTAMPTZ`, `update_time TIMESTAMPTZ`, `create_by BIGINT`, `update_by BIGINT`. Do not add new tables to `auditExcludeTables` — only `b_user` and `b_user_oauth` are exempt. + +### 3. Family/Space-Centric Data Model +All business data (finance, favorites, etc.) associates with `family_id`. Users link to families via `b_family_member`. Always filter queries by the current user's `family_id`. + +### 4. Family Feed Recording +Certain actions must call `recordFeed()` from `@/helpers/feed`. Use `{nickname}` placeholder in content — do NOT hardcode nicknames. The `loadFeeds()` helper replaces it dynamically. Scenarios: creating/joining family (`join`), renaming (`rename`), recording bills (`bill`), favoriting apps (`fav`). + +### 5. Frontend: Use Existing Libraries Only +Before writing any page/component, check `app-rn/package.json` for available dependencies. Use HeroUI Native for UI, `@legendapp/list` for long lists, `lucide-react-native` for icons, `sonner-native` for toasts. Use NativeWind `className` for styling, not inline `style={}`. + +### 6. Finance Convention +`type = 0` means expense, `type = 1` means income. Category icon names match iconfont names (e.g., `finance_jiaotong`, `finance_canyin`). + +## Key Files + +| File | Role | +|---|---| +| `PROJECT_CONTEXT.md` | Full project documentation, risks, feature status | +| `app-go/internal/bootstrap/app.go` | Backend initialization flow | +| `app-go/internal/router/router.go` | Route definitions | +| `app-go/pkg/db/meta_field.go` | Audit field auto-injection | +| `app-rn/src/app/index.tsx` | Startup/auth routing logic | +| `app-rn/src/request/index.ts` | Alova instance, auth interceptor | +| `app-rn/src/request/api.ts` | API wrapper functions | +| `app-rn/src/store/` | Zustand stores (user, family, finance, app, favorite) | +| `app-rn/src/layouts/` | Shell layouts (AppLayout, ModuleLayout, PageLayout) | +| `app-rn/src/configs/pages.ts` | Sub-app definitions | +| `app-rn/src/helpers/feed.ts` | Family feed recording | + +## Known Risks + +- Generic data APIs accept table names and raw SQL from the frontend — needs server-side whitelisting before production. +- `db.local.yml` contains database credentials and should never be committed. +- Finance category queries currently don't filter by `family_id`. +- Main tab `/space` route is missing its page file. +- `GetUniqueIdReq.Count` uses `binding` tag instead of `validate` tag — min/max rules may not run. diff --git a/code/app/app-go/go.mod b/code/app/app-go/go.mod index 3b0b6cbd..b1af5791 100644 --- a/code/app/app-go/go.mod +++ b/code/app/app-go/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/go-playground/validator/v10 v10.30.2 - github.com/gofiber/fiber/v3 v3.1.0 + github.com/gofiber/fiber/v3 v3.3.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgx/v5 v5.9.1 github.com/pkg/errors v0.9.1 @@ -14,7 +14,7 @@ require ( ) require ( - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.15 // indirect @@ -33,39 +33,42 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/fasthttp/websocket v1.5.12 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gofiber/schema v1.7.0 // indirect - github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/gofiber/contrib/v3/websocket v1.2.0 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/code/app/app-go/go.sum b/code/app/app-go/go.sum index 27f5207f..e2ddb14b 100644 --- a/code/app/app-go/go.sum +++ b/code/app/app-go/go.sum @@ -1,5 +1,7 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -39,12 +41,16 @@ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqx github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= +github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -57,12 +63,20 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/contrib/v3/websocket v1.2.0 h1:wjkzC3exbhRL3cPCVLRYN3MFq3yhMvtBeGwqEYQk+yc= +github.com/gofiber/contrib/v3/websocket v1.2.0/go.mod h1:fpqdn3mVAKAKfSOt8yHXhO70bZ1mz6z6NhY87CjM3k8= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= +github.com/gofiber/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8= +github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= +github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs= +github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -79,6 +93,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -89,6 +105,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= @@ -99,10 +117,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE= +github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761/go.mod h1:Vi9gvHvTw4yCUHIznFl5TPULS7aXwgaTByGeBY75Wko= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -122,10 +144,14 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -140,15 +166,23 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/code/app/app-go/internal/handle/data_list.go b/code/app/app-go/internal/handle/data_list.go index a5de4356..257418cd 100644 --- a/code/app/app-go/internal/handle/data_list.go +++ b/code/app/app-go/internal/handle/data_list.go @@ -50,6 +50,31 @@ func LoadDataBySql(c fiber.Ctx) error { return httpx.OK(c, data) } +func LoadDataPage(c fiber.Ctx) error { + var req types.LoadDataPageReq + + err := httpx.BindAndValidate(c, &req) + + if err != nil { + return errors.WithStack(err) + } + + dbClient := db.New() + + rows, total, err := dbClient.LoadDataPage(c.Context(), req.ViewName, req.SearchCondition, req.OrderBy, req.SearchColumns, req.Args, req.Page, req.PageSize) + + if err != nil { + return errors.WithStack(err) + } + + return httpx.OK(c, types.LoadDataPageRes{ + Rows: rows, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }) +} + func GetUniqueId(c fiber.Ctx) error { var req types.GetUniqueIdReq diff --git a/code/app/app-go/internal/router/router.go b/code/app/app-go/internal/router/router.go index 88475780..12c3f3ad 100644 --- a/code/app/app-go/internal/router/router.go +++ b/code/app/app-go/internal/router/router.go @@ -4,7 +4,9 @@ import ( "allapp-go/internal/config" "allapp-go/internal/handle" "allapp-go/internal/middleware" + "allapp-go/internal/ws" + "github.com/gofiber/contrib/v3/websocket" "github.com/gofiber/fiber/v3" ) @@ -15,6 +17,7 @@ func SetupRouter(app *fiber.App, cfg *config.Config) { data := api.Group("/data", middleware.Auth()) data.Post("/loadData", handle.LoadData) + data.Post("/loadDataPage", handle.LoadDataPage) data.Post("/loadDataBySql", handle.LoadDataBySql) data.Post("/saveData", handle.SaveData) data.Post("/getUniqueId", handle.GetUniqueId) @@ -31,4 +34,8 @@ func SetupRouter(app *fiber.App, cfg *config.Config) { bind := api.Group("/bind", middleware.Auth()) bind.Post("/qq", handle.BindQq) bind.Post("/wechat", handle.BindWechat) + + // ==================== chat ==================== + chat := api.Group("/chat") + chat.Get("/ws", ws.AuthWS(), websocket.New(ws.HandleWebSocket)) } diff --git a/code/app/app-go/internal/types/data_list.go b/code/app/app-go/internal/types/data_list.go index a6939a6f..6140e3c0 100644 --- a/code/app/app-go/internal/types/data_list.go +++ b/code/app/app-go/internal/types/data_list.go @@ -13,6 +13,23 @@ type LoadDataBySqlReq struct { Args []any `json:"args"` } +type LoadDataPageReq struct { + ViewName string `json:"view_name" validate:"required"` + SearchCondition string `json:"search_condition"` + OrderBy string `json:"order_by"` + SearchColumns []string `json:"search_columns"` + Args []any `json:"args"` + Page int `json:"page" validate:"required,min=1"` + PageSize int `json:"page_size" validate:"required,min=1,max=100"` +} + +type LoadDataPageRes struct { + Rows []map[string]any `json:"rows"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + type GetUniqueIdReq struct { Count int `json:"count" binding:"required,min=1,max=100"` } diff --git a/code/app/app-go/internal/ws/handler.go b/code/app/app-go/internal/ws/handler.go new file mode 100644 index 00000000..5d929cdd --- /dev/null +++ b/code/app/app-go/internal/ws/handler.go @@ -0,0 +1,372 @@ +package ws + +import ( + "allapp-go/internal/httpx" + "allapp-go/pkg/db" + "allapp-go/pkg/jwtx" + "allapp-go/pkg/uniqueid" + "context" + "encoding/json" + "log" + "strconv" + "strings" + "time" + + "github.com/gofiber/contrib/v3/websocket" + "github.com/gofiber/fiber/v3" +) + +// Client 单个 WebSocket 连接 +type Client struct { + conn *websocket.Conn + hub *Hub + userID int64 + familyID int64 + nickname string + avatar string + role int64 + send chan []byte +} + +// clientSendMsg 客户端发送的原始消息 +type clientSendMsg struct { + Type string `json:"type"` // send | recall | delete + Content string `json:"content"` + MsgType int `json:"msg_type"` // 0=text 1=image 2=video 3=file 4=forward + SourceTable string `json:"source_table"` + SourceID int64 `json:"source_id"` + MessageID int64 `json:"message_id"` + Attachments []attachmentInput `json:"attachments"` +} + +type attachmentInput struct { + FileKey string `json:"file_key"` + FileName string `json:"file_name"` + FileType int `json:"file_type"` + MimeType string `json:"mime_type"` + FileSize int64 `json:"file_size"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` +} + +// AuthWS WebSocket 鉴权中间件 +func AuthWS() fiber.Handler { + return func(c fiber.Ctx) error { + if !websocket.IsWebSocketUpgrade(c) { + return fiber.ErrUpgradeRequired + } + + token := strings.TrimSpace(c.Query("token")) + familyIDStr := c.Query("family_id") + + if token == "" || familyIDStr == "" { + return httpx.Unauthorized(c, "缺少 token 或 family_id") + } + + claims, ok := jwtx.VerifyToken(c.Context(), token) + if !ok { + return httpx.Unauthorized(c, "登录已过期") + } + + userID := claims.Data.UserID + + familyID, err := strconv.ParseInt(familyIDStr, 10, 64) + if err != nil { + return httpx.Unauthorized(c, "family_id 格式错误") + } + + // 验证用户属于该家庭,同时获取 nickname / avatar / role + dbClient := db.New() + rows, err := dbClient.LoadData(c.Context(), "b_family_member", + "user_id = ? AND family_id = ?", "", nil, + []any{userID, familyID}) + if err != nil || len(rows) == 0 { + return httpx.Unauthorized(c, "不属于该家庭") + } + + member := rows[0] + + // 存入 Locals,WebSocket handler 中通过 conn.Locals 读取 + c.Locals("user_id", userID) + c.Locals("family_id", familyID) + c.Locals("nickname", toString(member["nickname"])) + c.Locals("avatar", toString(member["avatar"])) + c.Locals("role", toInt64(member["role"])) + + return c.Next() + } +} + +// HandleWebSocket WebSocket 连接处理 +func HandleWebSocket(conn *websocket.Conn) { + userID := conn.Locals("user_id").(int64) + familyID := conn.Locals("family_id").(int64) + nickname := conn.Locals("nickname").(string) + avatar := conn.Locals("avatar").(string) + role := conn.Locals("role").(int64) + + client := &Client{ + conn: conn, + hub: GetHub(), + userID: userID, + familyID: familyID, + nickname: nickname, + avatar: avatar, + role: role, + send: make(chan []byte, 64), + } + + client.hub.Join(familyID, client) + defer client.hub.Leave(familyID, userID) + + go client.writePump() + client.readPump() +} + +const ( + // 心跳间隔 + pingPeriod = 10 * time.Second + // 读超时(超时未收到任何消息则断开) + readDeadline = 30 * time.Second +) + +// writePump 从 send 通道写入 WebSocket,同时定期发 ping +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case msg, ok := <-c.send: + if !ok { + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// readPump 读取 WebSocket 消息并分发 +func (c *Client) readPump() { + defer close(c.send) + + // 设置 pong 处理器:收到 pong 时重置读超时 + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(readDeadline)) + return nil + }) + c.conn.SetReadDeadline(time.Now().Add(readDeadline)) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + break + } + + var msg clientSendMsg + if err := json.Unmarshal(raw, &msg); err != nil { + continue + } + + switch msg.Type { + case "send": + c.handleSend(msg) + case "recall": + c.handleRecall(msg) + case "delete": + c.handleDelete(msg) + } + } +} + +// handleSend 处理发送消息 +func (c *Client) handleSend(msg clientSendMsg) { + ctx := context.Background() + dbClient := db.New() + + msgID := uniqueid.NextId() + now := time.Now() + + // 转发:查源表生成 source_data + var sourceData string + if msg.MsgType == 4 && msg.SourceTable != "" && msg.SourceID > 0 { + rows, err := dbClient.LoadData(ctx, msg.SourceTable, + "id = ?", "", nil, []any{msg.SourceID}) + if err == nil && len(rows) > 0 { + data, _ := json.Marshal(rows[0]) + sourceData = string(data) + } + } + + // 插入 b_chat + chatData := map[string]any{ + "id": msgID, + "family_id": c.familyID, + "user_id": c.userID, + "content": msg.Content, + "type": msg.MsgType, + "source_table": msg.SourceTable, + "source_id": msg.SourceID, + "source_data": sourceData, + } + if err := dbClient.Insert(ctx, "b_chat", "id", chatData); err != nil { + log.Printf("[chat] insert b_chat error: %v", err) + return + } + + // 附件 → 批量插入 b_file + if len(msg.Attachments) > 0 { + fileInserts := make([]map[string]any, 0, len(msg.Attachments)) + for i, att := range msg.Attachments { + fileInserts = append(fileInserts, map[string]any{ + "id": uniqueid.NextId(), + "target_id": msgID, + "file_key": att.FileKey, + "file_name": att.FileName, + "file_type": att.FileType, + "mime_type": att.MimeType, + "file_size": att.FileSize, + "width": att.Width, + "height": att.Height, + "duration": att.Duration, + "sort_order": i, + }) + } + if err := dbClient.BatchInsert(ctx, "b_file", "id", fileInserts); err != nil { + log.Printf("[chat] insert b_file error: %v", err) + } + } + + // 组装附件输出 + attachmentsOut := make([]map[string]any, 0) + for _, att := range msg.Attachments { + attachmentsOut = append(attachmentsOut, map[string]any{ + "file_key": att.FileKey, + "file_name": att.FileName, + "file_type": att.FileType, + "mime_type": att.MimeType, + "file_size": att.FileSize, + "width": att.Width, + "height": att.Height, + "duration": att.Duration, + }) + } + if attachmentsOut == nil { + attachmentsOut = []map[string]any{} + } + + // 广播 + c.hub.Broadcast(c.familyID, map[string]any{ + "type": "message", + "data": map[string]any{ + "id": msgID, + "family_id": c.familyID, + "user_id": c.userID, + "nickname": c.nickname, + "avatar": c.avatar, + "content": msg.Content, + "msg_type": msg.MsgType, + "source_table": msg.SourceTable, + "source_id": msg.SourceID, + "source_data": sourceData, + "attachments": attachmentsOut, + "recalled": 0, + "deleted": 0, + "create_time": now.Format("2006-01-02 15:04:05"), + }, + }) +} + +// handleRecall 处理撤回(发送者 2 分钟内) +func (c *Client) handleRecall(msg clientSendMsg) { + ctx := context.Background() + dbClient := db.New() + + rows, err := dbClient.LoadData(ctx, "b_chat", + "id = ?", "", nil, []any{msg.MessageID}) + if err != nil || len(rows) == 0 { + return + } + + chatMsg := rows[0] + + // 校验:只能撤回自己的消息 + msgUserID, _ := chatMsg["user_id"].(int64) + if msgUserID != c.userID { + return + } + + // 校验:2 分钟内 + createTime, _ := chatMsg["create_time"].(time.Time) + if time.Since(createTime) > 2*time.Minute { + return + } + + // 更新 + if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{ + "id": msg.MessageID, + "recalled": 1, + }); err != nil { + log.Printf("[chat] recall error: %v", err) + return + } + + c.hub.Broadcast(c.familyID, map[string]any{ + "type": "message_recalled", + "message_id": msg.MessageID, + }) +} + +// handleDelete 处理删除(仅管理员 role=0) +func (c *Client) handleDelete(msg clientSendMsg) { + if c.role != 0 { + return + } + + ctx := context.Background() + dbClient := db.New() + + if err := dbClient.Update(ctx, "b_chat", "id", map[string]any{ + "id": msg.MessageID, + "deleted": 1, + }); err != nil { + log.Printf("[chat] delete error: %v", err) + return + } + + c.hub.Broadcast(c.familyID, map[string]any{ + "type": "message_deleted", + "message_id": msg.MessageID, + }) +} + +func toString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func toInt64(v any) int64 { + switch val := v.(type) { + case int64: + return val + case int: + return int64(val) + case int32: + return int64(val) + case float64: + return int64(val) + } + return 0 +} diff --git a/code/app/app-go/internal/ws/hub.go b/code/app/app-go/internal/ws/hub.go new file mode 100644 index 00000000..b264e05a --- /dev/null +++ b/code/app/app-go/internal/ws/hub.go @@ -0,0 +1,132 @@ +package ws + +import ( + "encoding/json" + "sync" +) + +// OnlineUser 在线成员信息 +type OnlineUser struct { + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// Room 按 family_id 分组的聊天室 +type Room struct { + clients map[int64]*Client // user_id → Client + mu sync.RWMutex +} + +// Hub 管理所有 Room +type Hub struct { + rooms map[int64]*Room // family_id → Room + mu sync.RWMutex +} + +var defaultHub = &Hub{ + rooms: make(map[int64]*Room), +} + +func GetHub() *Hub { + return defaultHub +} + +// getOrCreateRoom 获取或创建 Room +func (h *Hub) getOrCreateRoom(familyID int64) *Room { + h.mu.Lock() + defer h.mu.Unlock() + + room, ok := h.rooms[familyID] + if !ok { + room = &Room{ + clients: make(map[int64]*Client), + } + h.rooms[familyID] = room + } + return room +} + +// Join 用户加入房间,广播 online 事件 +func (h *Hub) Join(familyID int64, client *Client) { + room := h.getOrCreateRoom(familyID) + + room.mu.Lock() + room.clients[client.userID] = client + onlineUsers := h.collectOnlineUsers(room) + room.mu.Unlock() + + h.broadcastOnlineUsers(familyID, onlineUsers) +} + +// Leave 用户离开房间,广播 offline 事件 +func (h *Hub) Leave(familyID int64, userID int64) { + room := h.getOrCreateRoom(familyID) + + room.mu.Lock() + delete(room.clients, userID) + onlineUsers := h.collectOnlineUsers(room) + room.mu.Unlock() + + // 广播 offline 给房间内其他人 + msg, _ := json.Marshal(map[string]any{ + "type": "offline", + "user_id": userID, + }) + h.broadcastRaw(familyID, msg) + + // 广播更新后的在线列表 + h.broadcastOnlineUsers(familyID, onlineUsers) +} + +// Broadcast 向房间内所有连接广播消息 +func (h *Hub) Broadcast(familyID int64, msg any) { + data, err := json.Marshal(msg) + if err != nil { + return + } + h.broadcastRaw(familyID, data) +} + +// broadcastRaw 向房间内所有连接广播原始字节 +func (h *Hub) broadcastRaw(familyID int64, data []byte) { + h.mu.RLock() + room, ok := h.rooms[familyID] + h.mu.RUnlock() + if !ok { + return + } + + room.mu.RLock() + defer room.mu.RUnlock() + + for _, client := range room.clients { + select { + case client.send <- data: + default: + // 发送通道满则跳过 + } + } +} + +// collectOnlineUsers 收集在线用户列表(调用方需持有 room.mu 锁) +func (h *Hub) collectOnlineUsers(room *Room) []OnlineUser { + users := make([]OnlineUser, 0, len(room.clients)) + for _, client := range room.clients { + users = append(users, OnlineUser{ + UserID: client.userID, + Nickname: client.nickname, + Avatar: client.avatar, + }) + } + return users +} + +// broadcastOnlineUsers 广播在线用户列表 +func (h *Hub) broadcastOnlineUsers(familyID int64, users []OnlineUser) { + msg, _ := json.Marshal(map[string]any{ + "type": "online", + "users": users, + }) + h.broadcastRaw(familyID, msg) +} diff --git a/code/app/app-go/pkg/db/select.go b/code/app/app-go/pkg/db/select.go index 5dcaa358..eeee0cb2 100644 --- a/code/app/app-go/pkg/db/select.go +++ b/code/app/app-go/pkg/db/select.go @@ -50,6 +50,69 @@ func (c *Client) LoadData( return c.query(ctx, sql, args...) } +func (c *Client) LoadDataPage( + ctx context.Context, + viewName string, + searchCondition string, + orderBy string, + searchColumns []string, + args []any, + page int, + pageSize int, +) ([]map[string]any, int, error) { + + selectCols := "*" + if len(searchColumns) > 0 { + selectCols = strings.Join(searchColumns, ", ") + } + + where := "" + if searchCondition != "" { + where = " WHERE " + searchCondition + } + + // 1. COUNT 查询 + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", viewName, where) + countSQL = convertPlaceholder(countSQL) + + var total int + err := c.pool.QueryRow(ctx, countSQL, args...).Scan(&total) + if err != nil { + return nil, 0, err + } + + // 2. 分页数据查询 + order := "" + if orderBy != "" { + order = " ORDER BY " + orderBy + } + + offset := (page - 1) * pageSize + dataSQL := fmt.Sprintf( + "SELECT %s FROM %s%s%s LIMIT %d OFFSET %d", + selectCols, + viewName, + where, + order, + pageSize, + offset, + ) + + logger.FromCtx(ctx).Info("LoadDataPage", + zap.String("countSQL", countSQL), + zap.String("dataSQL", dataSQL), + zap.Any("args", args), + zap.Int("total", total), + ) + + rows, err := c.query(ctx, dataSQL, args...) + if err != nil { + return nil, 0, err + } + + return rows, total, nil +} + func (c *Client) LoadDataBySQL( ctx context.Context, sql string, diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index 112575b8..224010be 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -1,104 +1,436 @@ -import React, { useState } from "react"; -import { View, Text, Button, ScrollView } from "react-native"; import LayoutView from "@/components/layout/view"; -import DateTime from "@/components/DateTime"; -import DateTimeRange from "@/components/DateTimeRange"; +import EmptyState from "@/components/EmptyStatus"; +import { loadDataApi, loadDataPageApi } from "@/request/api"; +import { useUserStore, useFamilyStore } from "@/store"; +import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket"; +import { cn } from "@/utils/cn"; +import { buildFileUrl } from "@/utils/file"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FlatList, Image, Text, TextInput, View, Keyboard } from "react-native"; +import { useRouter } from "expo-router"; +import { + Button, + Spinner, + BottomSheet, + PressableFeedback, +} from "heroui-native"; +import { SendHorizonalIcon, SearchIcon } from "lucide-react-native"; -export default function ChatTest() { - // --- DateTime (单选) --- - const [showDate, setShowDate] = useState(false); - const [dateValue, setDateValue] = useState("2026-06-23"); +function formatTime(ts: string) { + if (!ts) return ""; + const d = new Date(ts.replace(" ", "T") + "Z"); + const h = d.getHours().toString().padStart(2, "0"); + const m = d.getMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; +} - const [showTime, setShowTime] = useState(false); - const [timeValue, setTimeValue] = useState("20:00:00"); +// ====== 消息气泡 ====== +function ChatMessageItem({ + item, + isSelf, + members, + isAdmin, + onRecall, + onDelete, +}: { + item: ChatMessage; + isSelf: boolean; + members: Record; + isAdmin: boolean; + onRecall: (id: number) => void; + onDelete: (id: number) => void; +}) { + const router = useRouter(); + const member = members[item.user_id]; + const nickname = item.nickname || member?.nickname || "未知"; + const [showActions, setShowActions] = useState(false); - const [showDatetime, setShowDatetime] = useState(false); - const [datetimeValue, setDatetimeValue] = useState("2026-06-23 20:00"); + const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime(); + const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000; + const canRecall = isSelf && within2Min; + const canDelete = isAdmin; - const [showMonth, setShowMonth] = useState(false); - const [monthValue, setMonthValue] = useState("2026-06"); + // 撤回 + if (item.recalled === 1) { + return ( + + {nickname} 撤回了一条消息 + + ); + } - const [showYear, setShowYear] = useState(false); - const [yearValue, setYearValue] = useState("2026"); + return ( + <> + (canRecall || canDelete) && setShowActions(true)} + > + + {!isSelf && ( + router.navigate(`/family/member/${item.user_id}` as any)}> + {nickname} + + + )} - // --- DateTimeRange (范围) --- - const [showRange, setShowRange] = useState(false); - const [rangeStart, setRangeStart] = useState(""); - const [rangeEnd, setRangeEnd] = useState(""); + {/* 文本 */} + {item.msg_type === 0 && ( + + + {item.content} + + + )} + + {/* 图片 */} + {item.msg_type === 1 && item.attachments && item.attachments.length > 0 && ( + + {item.attachments.map((att, i) => ( + + ))} + + )} + + {/* 视频 */} + {item.msg_type === 2 && item.attachments && item.attachments.length > 0 && ( + + ▶ + {item.attachments[0].duration ? ( + + {item.attachments[0].duration}s + + ) : null} + + )} + + {/* 文件 */} + {item.msg_type === 3 && item.attachments && item.attachments.length > 0 && ( + + 📎 + + + {item.attachments[0].file_name} + + + {item.attachments[0].file_size + ? (item.attachments[0].file_size / 1024).toFixed(0) + " KB" + : ""} + + + + )} + + {/* 转发 */} + {item.msg_type === 4 && item.source_data && ( + + + 来自 {item.source_table} + + {(() => { + try { + const d = JSON.parse(item.source_data); + return Object.entries(d) + .filter(([k]) => !["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k)) + .map(([k, v]) => `${k}: ${v}`) + .join(" "); + } catch { + return item.source_data; + } + })()} + + + {item.content ? ( + + {item.content} + + ) : null} + + )} + + {formatTime(item.create_time)} + + + + + {/* 操作面板 */} + setShowActions(false)} + snapPoints={["auto"]} + > + + {canRecall && ( + + )} + {canDelete && ( + + )} + + + + + ); +} + +// ====== 在线头像栏 ====== +function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) { + const router = useRouter(); + return ( + + + {users.length} 人在线 + {users.slice(0, 6).map((u) => ( + router.navigate(`/family/member/${u.user_id}` as any)} + > + + {u.avatar ? ( + + ) : ( + + {u.nickname?.charAt(0) || "?"} + + )} + + + + ))} + + + + + + + + + ); +} + +// ====== 页面 ====== +export default function ChatPage() { + const router = useRouter(); + const userId = useUserStore((s) => s.userInfo.id); + const familyId = useFamilyStore((s) => s.currentFamily.family_id); + + const [history, setHistory] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [members, setMembers] = useState>({}); + const [inputText, setInputText] = useState(""); + const [adminRole, setAdminRole] = useState(0); + + const flatListRef = useRef(null); + const ws = useChatWebSocket(); + + // 成员映射 + useEffect(() => { + if (!familyId) return; + loadDataApi({ + view_name: "b_family_member", + search_condition: "family_id = ?", + search_columns: ["user_id", "nickname", "avatar", "role"], + args: [familyId], + order_by: "", + }).then((res) => { + if (res.isSuccess && res.data) { + const map: Record = {}; + for (const m of res.data) map[m.user_id] = m; + setMembers(map); + const me = map[userId]; + if (me) setAdminRole(me.role ?? 99); + } + }); + }, [familyId, userId]); + + // 加载历史 + const loadHistory = useCallback( + async (p: number) => { + if (!familyId) return; + const res = await loadDataPageApi({ + view_name: "b_chat", + search_condition: "family_id = ? AND deleted = 0", + order_by: "create_time DESC", + args: [familyId], + page: p, + page_size: 50, + search_columns: [], + }); + if (res.isSuccess && res.data) { + const rows = res.data.rows as ChatMessage[]; + const mediaIds = rows.filter((r) => r.msg_type >= 1 && r.msg_type <= 3).map((r) => r.id); + if (mediaIds.length > 0) { + const fileRes = await loadDataApi({ + view_name: "b_file", + search_condition: `target_id IN (${mediaIds.join(",")})`, + order_by: "sort_order ASC", + args: [], + search_columns: [], + }); + if (fileRes.isSuccess && fileRes.data) { + const fileMap: Record = {}; + for (const f of fileRes.data) { + if (!fileMap[f.target_id]) fileMap[f.target_id] = []; + fileMap[f.target_id].push(f); + } + for (const r of rows) { + if (fileMap[r.id]) (r as any).attachments = fileMap[r.id]; + } + } + } + if (p === 1) setHistory(rows.reverse()); + else setHistory((prev) => [...rows.reverse(), ...prev]); + setHasMore(rows.length >= 50); + } + }, + [familyId] + ); + + useEffect(() => { + loadHistory(1).finally(() => setLoading(false)); + }, [loadHistory]); + + const handleLoadMore = useCallback(async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + const nextPage = page + 1; + await loadHistory(nextPage); + setPage(nextPage); + setLoadingMore(false); + }, [loadingMore, hasMore, page, loadHistory]); + + // 合并实时消息 + const allMessages = (() => { + const ids = new Set(history.map((m) => m.id)); + const merged = [...history]; + for (const m of ws.messages) { + if (!ids.has(m.id)) { + merged.push(m); + ids.add(m.id); + } + } + return merged; + })(); + + // 发送 + const handleSend = useCallback(() => { + const text = inputText.trim(); + if (!text) return; + setInputText(""); + Keyboard.dismiss(); + ws.send({ type: "send", content: text }); + }, [inputText, ws]); + + const handleRecall = useCallback((id: number) => ws.recall(id), [ws]); + const handleDelete = useCallback((id: number) => ws.deleteMessage(id), [ws]); + + if (loading) { + return ( + + + + ); + } return ( - - - - {/* DateTime */} - DateTime(单选) - - + )} + + {/* ─── 关闭按钮 ─── */} router.back()} diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx new file mode 100644 index 00000000..7f2a59bc --- /dev/null +++ b/code/app/app-rn/src/app/(main)/chat/history.tsx @@ -0,0 +1,188 @@ +import LayoutView from "@/components/layout/view"; +import PageLayout from "@/layouts/PageLayout"; +import EmptyState from "@/components/EmptyStatus"; +import { loadDataApi, loadDataPageApi } from "@/request/api"; +import { useFamilyStore } from "@/store"; +import { cn } from "@/utils/cn"; +import { useCallback, useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import { useRouter } from "expo-router"; +import { SearchField, Spinner, PressableFeedback } from "heroui-native"; + +const MSG_TYPES = [ + { label: "全部", value: -1 }, + { label: "文本", value: 0 }, + { label: "图片", value: 1 }, + { label: "视频", value: 2 }, + { label: "文件", value: 3 }, + { label: "转发", value: 4 }, +]; + +function formatTime(ts: string) { + if (!ts) return ""; + const d = new Date(ts.replace(" ", "T") + "Z"); + const m = (d.getMonth() + 1).toString().padStart(2, "0"); + const day = d.getDate().toString().padStart(2, "0"); + const h = d.getHours().toString().padStart(2, "0"); + const min = d.getMinutes().toString().padStart(2, "0"); + return `${m}-${day} ${h}:${min}`; +} + +export default function ChatHistoryPage() { + const familyId = useFamilyStore((s) => s.currentFamily.family_id); + + const [keyword, setKeyword] = useState(""); + const [typeFilter, setTypeFilter] = useState(-1); + const [results, setResults] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [searched, setSearched] = useState(false); + const [memberMap, setMemberMap] = useState>({}); + + // 加载成员昵称映射 + useEffect(() => { + if (!familyId) return; + loadDataApi({ + view_name: "b_family_member", + search_condition: "family_id = ?", + search_columns: ["user_id", "nickname"], + args: [familyId], + order_by: "", + }).then((res) => { + if (res.isSuccess && res.data) { + const map: Record = {}; + for (const m of res.data) map[m.user_id] = m.nickname || "未知"; + setMemberMap(map); + } + }); + }, [familyId]); + + const doSearch = useCallback( + async (p: number) => { + if (!familyId || !keyword.trim()) return; + + if (p === 1) setLoading(true); + else setLoadingMore(true); + + try { + const escaped = keyword.trim().replace(/'/g, "''"); + let condition = `family_id = ? AND deleted = 0 AND content ILIKE '%${escaped}%'`; + if (typeFilter >= 0) condition += ` AND type = ${typeFilter}`; + + const res = await loadDataPageApi({ + view_name: "b_chat", + search_condition: condition, + order_by: "create_time DESC", + args: [familyId], + page: p, + page_size: 30, + search_columns: [], + }); + + if (res.isSuccess && res.data) { + if (p === 1) setResults(res.data.rows); + else setResults((prev) => [...prev, ...res.data.rows]); + setHasMore(res.data.rows.length >= 30); + setPage(p); + } + } finally { + setLoading(false); + setLoadingMore(false); + setSearched(true); + } + }, + [familyId, keyword, typeFilter] + ); + + const handleLoadMore = () => { + if (loadingMore || !hasMore) return; + doSearch(page + 1); + }; + + const router = useRouter(); + const typeLabel = (t: number) => MSG_TYPES.find((m) => m.value === t)?.label || "未知"; + + return ( + + + + {/* 搜索栏 */} + + + + doSearch(1)} + returnKeyType="search" + /> + + + + {/* 类型筛选 */} + + {MSG_TYPES.map((t) => ( + setTypeFilter(t.value)}> + + + {t.label} + + + + + ))} + + + + {/* 结果列表 */} + String(item.id)} + renderItem={({ item }: { item: any }) => ( + + + router.navigate(`/family/member/${item.user_id}` as any)} + > + + {memberMap[item.user_id] || "未知"} + + + + {formatTime(item.create_time)} + + {typeLabel(item.type)} + + + + {item.content} + + + )} + onEndReached={handleLoadMore} + onEndReachedThreshold={0.3} + ListEmptyComponent={ + loading ? ( + + ) : searched ? ( + + ) : null + } + ListFooterComponent={ + loadingMore ? ( + + + + ) : null + } + /> + + + ); +} diff --git a/code/app/app-rn/src/app/(main)/family/entry.tsx b/code/app/app-rn/src/app/(main)/family/entry.tsx index 378e7177..833e3a86 100644 --- a/code/app/app-rn/src/app/(main)/family/entry.tsx +++ b/code/app/app-rn/src/app/(main)/family/entry.tsx @@ -273,7 +273,7 @@ export default function EntryFamily() { setCreateForm({ name: "" }); // 记录动态 - recordFeed(familyId, userStore.userInfo.id, "join", `{nickname} 创建了家庭「${createForm.name}」`); + recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`); enterFamily(familyId); } @@ -352,7 +352,7 @@ export default function EntryFamily() { setJoinForm({ invite_code: "" }); // 记录动态 - recordFeed(family.id, userStore.userInfo.id, "join", "{nickname} 加入了家庭"); + recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`); enterFamily(family.id); } diff --git a/code/app/app-rn/src/app/(main)/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx index 283d7c62..c77a66b2 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -422,8 +422,8 @@ export default function UserSetting() { recordFeed( familyStore.currentFamily.family_id, userStore.userInfo.id, - "rename", - `{nickname} 修改了昵称为「${renameForm.name}」`, + "family", + `${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`, ); toast.success("名称修改成功"); diff --git a/code/app/app-rn/src/app/(main)/tools/index.tsx b/code/app/app-rn/src/app/(main)/tools/index.tsx index d99b9ab4..2f6e326e 100644 --- a/code/app/app-rn/src/app/(main)/tools/index.tsx +++ b/code/app/app-rn/src/app/(main)/tools/index.tsx @@ -176,8 +176,8 @@ export default function Tools() { recordFeed( familyStore.currentFamily.family_id, userStore.userInfo?.id ?? 0, - "fav", - `{nickname} 收藏了「${subItem.title}」`, + "favorite", + `${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`, ); } }} diff --git a/code/app/app-rn/src/components/LoadingOverlay.tsx b/code/app/app-rn/src/components/LoadingOverlay.tsx new file mode 100644 index 00000000..a79fa657 --- /dev/null +++ b/code/app/app-rn/src/components/LoadingOverlay.tsx @@ -0,0 +1,23 @@ +import { View, Text } from "react-native"; +import { Spinner } from "heroui-native"; + +interface LoadingOverlayProps { + visible: boolean; + text?: string; +} + +export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) { + if (!visible) return null; + + return ( + + + + {text} + + + ); +} diff --git a/code/app/app-rn/src/helpers/feed.ts b/code/app/app-rn/src/helpers/feed.ts index d8e467af..faa3def7 100644 --- a/code/app/app-rn/src/helpers/feed.ts +++ b/code/app/app-rn/src/helpers/feed.ts @@ -1,6 +1,6 @@ -import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api"; +import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api"; -export type FeedType = "join" | "rename" | "bill" | "fav"; +export type FeedType = "family" | "finance" | "favorite"; export interface FeedRecord { id: number; @@ -12,25 +12,7 @@ export interface FeedRecord { create_time: string; } -/** 获取用户在家庭中的昵称(来自 b_family_member,非 b_user) */ -export async function getFamilyMemberNickname( - familyId: number, - userId: number, -): Promise { - const res = await loadDataApi({ - view_name: "b_family_member", - search_condition: "family_id = ? AND user_id = ?", - search_columns: ["nickname"], - order_by: "", - args: [familyId, userId], - }); - if (res.isSuccess && res.data?.length > 0) { - return res.data[0].nickname || "成员"; - } - return "成员"; -} - -/** 写入一条动态 — content 直接包含所有展示信息,用 {nickname} 占位 */ +/** 写入一条动态 */ export async function recordFeed( familyId: number, userId: number, @@ -60,7 +42,7 @@ export async function recordFeed( ]); } -/** 加载家庭动态列表 — 自动替换 {nickname} 占位符 */ +/** 加载家庭动态列表 */ export async function loadFeeds(familyId: number): Promise { const res = await loadDataApi({ view_name: "b_family_feed", @@ -69,18 +51,49 @@ export async function loadFeeds(familyId: number): Promise { search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"], args: [familyId], }); - const feeds = (res.data || []) as FeedRecord[]; + return (res.data || []) as FeedRecord[]; +} - // 批量加载昵称:收集唯一 user_id,逐个查 b_family_member - const uniqueUserIds = [...new Set(feeds.map((f) => f.user_id))]; - const nickMap = new Map(); - for (const uid of uniqueUserIds) { - nickMap.set(uid, await getFamilyMemberNickname(familyId, uid)); +/** 分页加载家庭动态列表 */ +export async function loadFeedsPage( + familyId: number, + page: number, + pageSize: number, + type?: string, + dateFrom?: string, + dateTo?: string, +): Promise<{ rows: FeedRecord[]; total: number; page: number; page_size: number }> { + const conditions: string[] = ["family_id = ?"]; + const args: any[] = [familyId]; + + if (type) { + conditions.push("type = ?"); + args.push(type); } - // 替换 {nickname} 占位符,保证每次展示都是最新昵称 - return feeds.map((f) => ({ - ...f, - content: f.content.replace(/\{nickname\}/g, nickMap.get(f.user_id) || "成员"), - })); + if (dateFrom) { + conditions.push("create_time >= ?"); + args.push(dateFrom); + } + + if (dateTo) { + conditions.push("create_time <= ?"); + args.push(dateTo + " 23:59:59"); + } + + const res = await loadDataPageApi({ + view_name: "b_family_feed", + search_condition: conditions.join(" AND "), + order_by: "create_time DESC", + search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"], + args, + page, + page_size: pageSize, + }); + + if (!res.isSuccess || !res.data) { + return { rows: [], total: 0, page, page_size: pageSize }; + } + + return { rows: res.data.rows as FeedRecord[], total: res.data.total, page: res.data.page, page_size: res.data.page_size }; } diff --git a/code/app/app-rn/src/hooks/useChatWebSocket.ts b/code/app/app-rn/src/hooks/useChatWebSocket.ts new file mode 100644 index 00000000..0d66e709 --- /dev/null +++ b/code/app/app-rn/src/hooks/useChatWebSocket.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner-native"; +import { useUserStore, useFamilyStore } from "@/store"; + +const RECONNECT_BASE = 1000; +const RECONNECT_MAX = 30000; + +export interface ChatMessage { + id: number; + family_id: number; + user_id: number; + nickname: string; + avatar: string; + content: string; + msg_type: number; + source_table: string | null; + source_id: number | null; + source_data: string | null; + attachments: Attachment[] | null; + recalled: number; + deleted: number; + create_time: string; +} + +export interface Attachment { + file_key: string; + file_name: string; + file_type: number; + mime_type: string; + file_size: number; + width?: number; + height?: number; + duration?: number; +} + +export interface OnlineUser { + user_id: number; + nickname: string; + avatar: string; +} + +interface SendPayload { + type: "send"; + content: string; + msg_type?: number; + source_table?: string; + source_id?: number; + attachments?: Omit[]; +} + +export function useChatWebSocket() { + const token = useUserStore((s) => s.userInfo?.token); + const familyId = useFamilyStore((s) => s.currentFamily?.family_id); + + const [messages, setMessages] = useState([]); + const [onlineUsers, setOnlineUsers] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + const wsRef = useRef(null); + const lastReceivedId = useRef(0); + const reconnectTimer = useRef | null>(null); + const reconnectDelay = useRef(RECONNECT_BASE); + const mounted = useRef(true); + const wasConnected = useRef(false); + + const buildWsUrl = useCallback(() => { + const apiUrl = process.env.EXPO_PUBLIC_API_URL || ""; + const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws"; + const host = apiUrl.replace(/^https?:\/\//, ""); + return `${wsProtocol}://${host}/app/chat/ws?token=${token}&family_id=${familyId}`; + }, [token, familyId]); + + const connect = useCallback(() => { + if (!token || !familyId) return; + if (wsRef.current) return; + + const url = buildWsUrl(); + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + if (!mounted.current) return; + setIsConnected(true); + reconnectDelay.current = RECONNECT_BASE; + if (wasConnected.current) { + toast.success("聊天已重连"); + } + wasConnected.current = true; + }; + + ws.onmessage = (event) => { + if (!mounted.current) return; + try { + const msg = JSON.parse(event.data); + handleMessage(msg); + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + if (!mounted.current) return; + setIsConnected(false); + wsRef.current = null; + if (wasConnected.current) { + toast.error("聊天已断开"); + } + scheduleReconnect(); + }; + + ws.onerror = () => { + ws.close(); + }; + }, [token, familyId, buildWsUrl]); + + const scheduleReconnect = useCallback(() => { + if (!mounted.current) return; + reconnectTimer.current = setTimeout(() => { + reconnectDelay.current = Math.min(reconnectDelay.current * 2, RECONNECT_MAX); + connect(); + }, reconnectDelay.current); + }, [connect]); + + const handleMessage = useCallback((msg: any) => { + switch (msg.type) { + case "message": { + const data = msg.data as ChatMessage; + if (data.id <= lastReceivedId.current) return; + lastReceivedId.current = data.id; + setMessages((prev) => [...prev, data]); + break; + } + case "message_recalled": { + setMessages((prev) => + prev.map((m) => (m.id === msg.message_id ? { ...m, recalled: 1 } : m)) + ); + break; + } + case "message_deleted": { + setMessages((prev) => prev.filter((m) => m.id !== msg.message_id)); + break; + } + case "online": { + setOnlineUsers(msg.users || []); + break; + } + case "offline": { + setOnlineUsers((prev) => + prev.filter((u) => u.user_id !== msg.user_id) + ); + break; + } + } + }, []); + + const send = useCallback((payload: SendPayload) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + wsRef.current.send(JSON.stringify(payload)); + }, []); + + const recall = useCallback((messageId: number) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + wsRef.current.send(JSON.stringify({ type: "recall", message_id: messageId })); + }, []); + + const deleteMessage = useCallback((messageId: number) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + wsRef.current.send(JSON.stringify({ type: "delete", message_id: messageId })); + }, []); + + useEffect(() => { + mounted.current = true; + connect(); + + return () => { + mounted.current = false; + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [connect]); + + return { + messages, + onlineUsers, + isConnected, + send, + recall, + deleteMessage, + }; +} diff --git a/code/app/app-rn/src/request/api.ts b/code/app/app-rn/src/request/api.ts index dfc036db..7591f65c 100644 --- a/code/app/app-rn/src/request/api.ts +++ b/code/app/app-rn/src/request/api.ts @@ -48,6 +48,27 @@ export function loadDataApi(params: LoadDataParams) { return alovaInstance.Post>(`/data/loadData`, params); } +interface LoadDataPageParams { + view_name: string; + search_condition: string; + order_by: string; + search_columns: string[]; + args: any[]; + page: number; + page_size: number; +} + +interface PageData { + rows: T[]; + total: number; + page: number; + page_size: number; +} + +export function loadDataPageApi(params: LoadDataPageParams) { + return alovaInstance.Post>>(`/data/loadDataPage`, params); +} + interface SaveDataParams { table_name: string; key_field: string; diff --git a/新建文本文档.txt b/新建文本文档.txt index a675b629..ffe513dc 100644 --- a/新建文本文档.txt +++ b/新建文本文档.txt @@ -30,4 +30,7 @@ https://api.code-relay.com/register?aff=el8a 这个送20 ,gpt5.5 https://helpcoder.cc/register?aff=qWV9 -这个送200 ,gpt系列 \ No newline at end of file +这个送200 ,gpt系列、 + + +sk-95bc4f401d8b499bbe073c828d176157 \ No newline at end of file