Compare commits

..
35 Commits
Author SHA1 Message Date
richjohnrambo 9bb479b506 feat: use floating customer service widget in pro
Deploy olons-server production / deploy (push) Successful in 3m1s
2026-10-21 13:15:15 +08:00
richjohnrambo 79c502cb20 fix: exclude test accounts from leaderboard
Deploy olons-server production / deploy (push) Successful in 4m29s
2026-10-20 23:35:17 +08:00
richjohnrambo 47b545d050 feat: directly add customer service friend
Deploy olons-server production / deploy (push) Successful in 3m26s
2026-10-20 22:24:56 +08:00
richjohnrambo 78165b7a15 feat: use login popup announcement on home
Deploy olons-server production / deploy (push) Successful in 4m57s
2026-10-20 22:13:51 +08:00
Your Name 6954d375b5 fix: select support identity by IM tenant
Deploy olons-server production / deploy (push) Successful in 5m4s
2026-10-20 21:42:43 +08:00
Your Name 975a2a2ed2 fix: preserve pending support state on private profiles
Deploy olons-server production / deploy (push) Successful in 2m42s
2026-10-20 21:31:44 +08:00
Your Name 5f85472757 fix: handle private pending support relationships
Deploy olons-server production / deploy (push) Successful in 3m23s
2026-10-20 21:25:19 +08:00
richjohnrambo 92b5656f3f fix: reprovision IM member identities across tenants
Deploy olons-server production / deploy (push) Successful in 2m40s
2026-10-20 20:55:10 +08:00
Your Name cdebd0a404 fix(ci): deploy olons server from managed compose env
Deploy olons-server production / deploy (push) Successful in 32s
2026-10-20 17:49:03 +08:00
richjohnrambo ba87d70478 fix: show customer chat before connection completes
Deploy olons-server production / deploy (push) Successful in 4m30s
2026-10-19 21:32:56 +08:00
Your Name db1367b8fe fix: render inline support images
Deploy olons-server production / deploy (push) Successful in 4m1s
2026-10-19 18:06:00 +08:00
Your Name db18514e6d fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
2026-10-19 17:23:34 +08:00
Your Name c39ec78e89 docs: design support IM reliability fix 2026-10-19 16:50:29 +08:00
richjohnrambo e6c421ac3e fix: inject production IM API key
Deploy olons-server production / deploy (push) Successful in 37s
2026-10-19 13:33:10 +08:00
richjohnrambo 74785f948b fix: use personal login for default IM tenant
Deploy olons-server production / deploy (push) Successful in 2m48s
2026-10-19 13:25:32 +08:00
richjohnrambo f2f4720340 chore: expose support integration errors during deploy
Deploy olons-server production / deploy (push) Successful in 1m9s
2026-10-19 13:22:44 +08:00
richjohnrambo dffd47294e fix: preserve production compose variables
Deploy olons-server production / deploy (push) Successful in 1m9s
2026-10-19 13:20:27 +08:00
richjohnrambo 950890a734 fix: override production IM endpoints
Deploy olons-server production / deploy (push) Failing after 35s
2026-10-19 13:17:59 +08:00
richjohnrambo 179a9bdd16 fix: bind Olons support to production IM
Deploy olons-server production / deploy (push) Successful in 52s
2026-10-19 13:16:10 +08:00
richjohnrambo aa0e17f1f1 fix: inject IM internal secret in production
Deploy olons-server production / deploy (push) Successful in 33s
2026-10-19 13:13:08 +08:00
richjohnrambo 47bb4fe2ed Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m27s
2026-10-19 11:30:39 +08:00
richjohnrambo 355d59813b fix: use default IM tenant 2026-10-19 11:28:15 +08:00
dev 04a3a0f538 Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m50s
2026-10-19 11:14:26 +08:00
richjohnrambo 1c11c0fa82 fix: restore payment account details 2026-10-19 11:14:09 +08:00
dev 5e0abedbcc Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m45s
2026-10-19 09:34:44 +08:00
richjohnrambo 21371061ed feat: improve home notice ticker 2026-10-19 09:34:26 +08:00
Your Name 319768bd82 test: align deployment contract with production
Deploy olons-server production / deploy (push) Successful in 28s
2026-10-19 02:55:32 +08:00
dev d8facff163 Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 2m29s
2026-10-19 01:03:52 +08:00
richjohnrambo 2e47dd3935 fix: remove tenant dependency from member login 2026-10-19 01:03:31 +08:00
richjohnrambo 715740fc30 Merge remote-tracking branch 'refs/remotes/origin/dev' into HEAD
Deploy olons-server production / deploy (push) Successful in 3m10s
2026-10-19 00:31:28 +08:00
richjohnrambo 804318e5f5 fix: load mall claimed counts from database 2026-10-19 00:02:35 +08:00
richjohnrambo 8b773a1a1f feat: show mall claimed counts 2026-10-18 22:52:20 +08:00
richjohnrambo 86efdf4525 feat mobile handmade mall grid 2026-10-18 21:34:27 +08:00
richjohnrambo 5a0fe67027 fix mobile task rules and feature availability 2026-10-18 20:53:33 +08:00
richjohnrambo b79b781f15 feat: add object storage and points leaderboard 2026-10-18 18:11:34 +08:00
59 changed files with 2305 additions and 794 deletions
+16 -3
View File
@@ -40,7 +40,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")" SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" . docker build --build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro" docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA" docker push "$IMAGE:$SHORT_SHA"
@@ -64,5 +64,18 @@ jobs:
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes) SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \ printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin" "docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \ ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" '
"cd /opt/olns && docker compose pull olons-server && docker compose up -d --no-deps --wait olons-server" set -euo pipefail
cd /opt/olns
docker compose pull olons-server
docker compose up -d --no-deps --wait olons-server
docker compose exec -T olons-server sh -c '"'"'
test -n "$OLONS_IM_INTERNAL_SECRET"
test "$OLONS_IM_TENANT_CODE" = "default"
test "$OLONS_SERVER_DEFAULT_TENANT_ID" = "0"
test "$OLONS_IM_BASE_URL" = "http://im-server:6060"
test "$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"
test "$OLONS_IM_WS_URL" = "ws://im-server:6060/v0/channels"
test "$OLONS_IM_FILE_URL" = "http://im-server:6060/v0/file"
'"'"'
'
+2
View File
@@ -6,7 +6,9 @@ RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY web/ ./ COPY web/ ./
ARG VITE_CUSTOMER_SERVICE_PROVIDER=
ENV VITE_PUBLIC_PATH=/ ENV VITE_PUBLIC_PATH=/
ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}
RUN pnpm run build RUN pnpm run build
# ---- Stage 2: build the Go backend ---- # ---- Stage 2: build the Go backend ----
+1 -1
View File
@@ -71,7 +71,7 @@ X-Request-ID: <request id>
X-Device-ID: <optional device id> X-Device-ID: <optional device id>
``` ```
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
## 验证 ## 验证
-1
View File
@@ -18,7 +18,6 @@
"api_keys": [], "api_keys": [],
"session_key_pattern": "olons_server:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"finance_master_key": "olons-finance-master-key-v1-20261013", "finance_master_key": "olons-finance-master-key-v1-20261013",
"allow_dev_header_auth": true, "allow_dev_header_auth": true,
"required_api_key": false "required_api_key": false
-1
View File
@@ -18,7 +18,6 @@
"api_keys": [], "api_keys": [],
"session_key_pattern": "olons_server:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"finance_master_key": "olons-finance-master-key-v1-20261013", "finance_master_key": "olons-finance-master-key-v1-20261013",
"required_api_key": true "required_api_key": true
} }
@@ -0,0 +1,78 @@
# Customer Service IM Reliability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore customer-service message and image delivery after WebSocket interruptions and route every browser IM request through its current frontend origin.
**Architecture:** Browser-facing session endpoints are relative paths resolved against the current origin, while server-to-server calls use Docker-internal endpoints. Caddy routes the IM API, file service, and WebSocket before each site's frontend fallback. The mobile chat client reauthenticates and resubscribes after SDK reconnects.
**Tech Stack:** Go, Vue 3, TypeScript, Tinode SDK, Vitest, Docker Compose, Caddy, Python/Bash deployment contract tests.
---
### Task 1: Relative browser endpoint contract
**Files:**
- Modify: `internal/support/service.go`
- Modify: `internal/support/service_test.go`
- [ ] Write a failing Go test asserting `OpenSession` returns `/v1/im`, `/v0/channels`, and `/v0/file` even when internal IM configuration contains an absolute host.
- [ ] Run `go test ./internal/support -run TestOpenSessionReturnsSameOriginEndpoints -v` and confirm the absolute-host assertion failure.
- [ ] Return the three relative public endpoints from `OpenSession` without changing `imclient.Config()` used for server calls.
- [ ] Re-run the targeted test and `go test ./internal/support ./internal/imclient ./internal/httpapi`.
### Task 2: Mobile reconnect and media authentication
**Files:**
- Modify: `web/package.json`
- Modify: `web/pnpm-lock.yaml`
- Modify: `web/src/mobile/im.ts`
- Create: `web/src/mobile/im.test.ts`
- Modify: `web/src/mobile/views/SupportView.vue`
- [ ] Add Vitest and a test script.
- [ ] Write failing tests for same-origin endpoint resolution, initial connect/login/subscribe, reconnect login/resubscribe with later data, and intentional disconnect.
- [ ] Run `pnpm test --run` and confirm the failures are caused by missing reconnect behavior.
- [ ] Add serialized reconnect recovery, connection-state callbacks, relative endpoint resolution, and Header-based upload authentication.
- [ ] Add an offline/reconnecting banner, disable sending while disconnected, and add media error/retry UI.
- [ ] Run `pnpm test --run`, `pnpm run build`, and the Go package tests.
### Task 3: Remove fixed IM public origins from im_webapp
**Files:**
- Modify: `../im_webapp/tests/test_actions_workflow.py`
- Modify: `../im_webapp/.gitea/workflows/deploy.yml`
- Modify: `../im_webapp/Dockerfile`
- Modify: `../im_webapp/web/src/lib/config.ts`
- Modify or create: `../im_webapp/web/tests/same-origin-endpoints.test.mjs`
- [ ] Change contract tests first so they reject fixed production API/file build arguments and the legacy default WebSocket host.
- [ ] Run the Python and Node tests and confirm they fail on the current fixed values.
- [ ] Remove fixed endpoint build arguments, make file base default empty, and derive the fallback WebSocket host from `window.location.host`.
- [ ] Run Python contracts, Node tests, and `pnpm run build`.
### Task 4: Caddy same-origin route contract
**Files:**
- Modify: `../deploy/olns-stack/test-deployment.sh`
- Modify: `../deploy/olns-stack/Caddyfile`
- Modify: `../deploy/olns-stack/compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] Add failing deployment assertions requiring `/v1/im*`, `/v0/file*`, and `/v0/channels*` to be handled before frontend fallbacks for both site groups.
- [ ] Run `bash ../deploy/olns-stack/test-deployment.sh` and confirm failure.
- [ ] Add the Caddy route handlers with no-store responses.
- [ ] Switch server-side IM HTTP/WS/base values to `im-server:6060` in Compose and the OLONS deployment overlay.
- [ ] Re-run deployment contracts and workflow tests.
### Task 5: Integration and production verification
**Files:**
- Update: `../DEPLOYMENT_RECORD.md` with non-secret route and acceptance notes.
- [ ] Run full Go, web, workflow and deployment test suites and builds.
- [ ] Commit each repository, merge the task branch into current `pro`, and push `pro` only after all local gates pass.
- [ ] Back up `/opt/olns/Caddyfile` and Compose files, install the tested Caddy/Compose changes, validate Caddy, and reload atomically.
- [ ] Wait for both Actions deployments and verify image digests, container health and restart counts.
- [ ] Verify all five domains return IM responses instead of SPA HTML, WebSocket upgrades to 101, and file responses are not HTML.
- [ ] Exercise a real support session, interrupt/recover its network connection, and verify a subsequent text and image arrive without refreshing.
@@ -0,0 +1,43 @@
# 客服 IM 投递可靠性设计
## 目标
修复 OLONS 会员端在 WebSocket 异常断线后无法继续接收客服文本或图片的问题,并保证 IM API、文件和 WebSocket 全部通过用户当前访问的前端域名转发。
## 范围
- `olons_server` 会员客服客户端的断线恢复、连接状态和媒体加载错误处理。
- `olons_server` 客服会话返回给浏览器的公开端点。
- `im_webapp` 生产构建中的固定公网域名。
- 单机 Compose Caddy 的同源路径路由和部署契约。
- 生产发布及五个前端域名的文本、图片、WebSocket 验收。
不修改会员、消息或文件业务数据,不改支付、提现及其他业务功能。
## 架构
浏览器公开端点使用相对路径:`/v1/im`、`/v0/file`、`/v0/channels`。客户端以 `window.location.origin` 解析 HTTP/文件地址,并将当前页面协议转换为 `ws` 或 `wss`。因此同一镜像在 `olons.yuexuan.shop` 和三个公测域名下会自动使用当前域名,不读取或信任任意 Host Header。
Caddy 在五个前端域名中优先把上述路径代理到 `im-server:6060`,其余路径仍进入原来的前端或 OLONS 服务。代理响应明确禁止缓存。`olons-server` 自身调用 IM 时使用 Docker 网络中的 `im-server:6060`,不再绕行 Cloudflare。
## 断线恢复
`CustomerServiceChat` 保存 token、当前 Topic 和最新 seq。首次连接仍执行 connect、token 登录和历史订阅。SDK 重新建立 WebSocket 后,客户端再次 token 登录,重新订阅当前 Topic,并通过 later-data 查询补拉本地最后 seq 之后的数据。恢复过程串行化,避免多个 reconnect 回调造成重复登录或订阅。
页面接收连接状态:`connecting`、`online`、`reconnecting`、`offline`。断线时保留已显示消息但禁止发送,恢复成功后解除禁止。主动离开页面不触发恢复。
## 图片处理
上传使用 `X-MeetingIM-APIKey` 和 `Authorization: Token <token>` Header,不再把 token 放在 URL 查询参数中。下载继续使用当前域名;图片或视频加载失败时显示失败提示和重试入口,不能静默显示空白。
## 测试
- TypeScript 单测覆盖相对端点解析、首次订阅、断线重连、重新登录、重新订阅、补拉和主动断开。
- Go 单测覆盖客服会话始终返回相对公开端点。
- `im_webapp` 工作流契约禁止固定 `im-api.yuexuan.shop` 和旧 `118im.com` 文件/WS地址。
- 部署契约要求五个域名都有三条同源路由,并保证 catch-all 顺序正确。
- 生产验收验证 JSON API、文件、WebSocket 101、断线恢复后的文本和图片,不以数据库序号代替浏览器展示验收。
## 回滚
应用镜像可回滚到发布前 digest;Caddy 修改前保留生产文件副本。回滚只涉及 `olons-server`、`im-webapp` 和 Caddy,不修改数据库或 MinIO 数据。
@@ -54,7 +54,7 @@
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 | | `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 | | `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。 服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
### 3.3 登录会话 ### 3.3 登录会话
@@ -481,7 +481,7 @@ GET /v1/member-levels
GET /v1/leaderboard GET /v1/leaderboard
``` ```
接口要求登录,返回当前租户内已完成任务数量排名前 50 的有效会员。排名只统计 接口要求登录,返回当前租户内已完成领取订单总金额排名前 50 的有效会员。页面将订单总金额展示为领取积分,接口字段 `claimed_points` 的单位为分。排名只统计
`hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。 `hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。
```json ```json
@@ -492,7 +492,7 @@ GET /v1/leaderboard
"rank": 1, "rank": 1,
"username": "风***淡", "username": "风***淡",
"avatar_url": "https://cdn.example.com/avatar.png", "avatar_url": "https://cdn.example.com/avatar.png",
"completed_count": 12580, "claimed_points": 125800,
"status": "领取成功" "status": "领取成功"
} }
] ]
@@ -743,7 +743,7 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0
} }
``` ```
金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。 金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 直接读取商品表中的固定展示数,不在页面或接口中重新计算。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。
### 8.2 商品详情 ### 8.2 商品详情
+15 -1
View File
@@ -4,7 +4,9 @@ go 1.26.0
require ( require (
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/minio/minio-go/v7 v7.0.94
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.12.1 github.com/redis/go-redis/v9 v9.12.1
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
@@ -15,13 +17,25 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
) )
+27
View File
@@ -14,17 +14,28 @@ 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -35,8 +46,16 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM=
github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -51,16 +70,24 @@ github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgv
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -19,7 +19,7 @@ type profileRepository struct {
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) { func (r *profileRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil } func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
+4 -5
View File
@@ -11,7 +11,6 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
@@ -47,7 +46,7 @@ var (
type Repository interface { type Repository interface {
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
AccountByMobile(context.Context, int64, string) (*store.Account, error) AccountByMobile(context.Context, string) (*store.Account, error)
RecordLogin(context.Context, int64, int64) error RecordLogin(context.Context, int64, int64) error
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
@@ -179,7 +178,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
return nil, err return nil, err
} }
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{ account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID, TenantID: 0,
MemberNo: memberNo, MemberNo: memberNo,
Username: input.Username, Username: input.Username,
Mobile: input.Phone, Mobile: input.Phone,
@@ -237,7 +236,7 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 { if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
return nil, ErrRateLimited return nil, ErrRateLimited
} }
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone) account, err := s.repository.AccountByMobile(ctx, phone)
if err != nil { if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) { if errors.Is(err, store.ErrInvalidCredentials) {
s.recordLoginFailure(ctx, rateKey) s.recordLoginFailure(ctx, rateKey)
@@ -387,7 +386,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
func (s *Service) loginRateKey(phone string) string { func (s *Service) loginRateKey(phone string) string {
digest := sha256.Sum256([]byte(phone)) digest := sha256.Sum256([]byte(phone))
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:]) return "olons_server:auth:login-rate:" + hex.EncodeToString(digest[:])
} }
func (s *Service) recordLoginFailure(ctx context.Context, key string) { func (s *Service) recordLoginFailure(ctx context.Context, key string) {
+1 -1
View File
@@ -14,7 +14,7 @@ type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("should not be called") return nil, errors.New("should not be called")
} }
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) { func (fakeRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
return nil, errors.New("should not be called") return nil, errors.New("should not be called")
} }
func (fakeRepository) RecordLogin(context.Context, int64, int64) error { func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
+23 -2
View File
@@ -53,9 +53,30 @@ func New(cfg *config.Config) (*App, error) {
taskService := tasks.New(st) taskService := tasks.New(st)
mallService := mall.New(st) mallService := mall.New(st)
financeService := finance.New(st, redisClient, cfg.Auth) financeService := finance.New(st, redisClient, cfg.Auth)
attachmentService := attachments.New(st, cfg.Upload) uploadConfig := cfg.Upload
uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 5*time.Second)
uploadValues, err := st.UploadConfigValues(uploadCtx)
uploadCancel()
if err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
config.ApplyUploadSystemConfig(&uploadConfig, uploadValues)
config.ApplyUploadEnv(&uploadConfig)
if err = config.ValidateUpload(uploadConfig); err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
attachmentService, err := attachments.New(st, uploadConfig)
if err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth) supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: uploadConfig}, nil
} }
func (a *App) Handler() http.Handler { func (a *App) Handler() http.Handler {
+62 -11
View File
@@ -1,6 +1,7 @@
package attachments package attachments
import ( import (
"bytes"
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/rand" "crypto/rand"
@@ -8,12 +9,17 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
) )
const maxImageSize = 5 << 20 const maxImageSize = 5 << 20
@@ -27,10 +33,23 @@ type Repository interface {
type Service struct { type Service struct {
repository Repository repository Repository
cfg config.UploadConfig cfg config.UploadConfig
minio *minio.Client
} }
func New(repository Repository, cfg config.UploadConfig) *Service { func New(repository Repository, cfg config.UploadConfig) (*Service, error) {
return &Service{repository: repository, cfg: cfg} service := &Service{repository: repository, cfg: cfg}
if strings.EqualFold(cfg.Driver, "minio") {
client, err := minio.New(strings.TrimSpace(cfg.Endpoint), &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.Secure,
Region: cfg.Region,
})
if err != nil {
return nil, err
}
service.minio = client
}
return service, nil
} }
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) { func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
@@ -52,22 +71,54 @@ func (s *Service) UploadImage(ctx context.Context, memberID int64, name string,
return nil, err return nil, err
} }
fileName := hex.EncodeToString(random) + ext fileName := hex.EncodeToString(random) + ext
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil { storedPath, publicURL, err := s.store(ctx, fileName, mimeType, data)
return nil, err if err != nil {
}
path := filepath.Join(s.cfg.Dir, fileName)
if err = os.WriteFile(path, data, 0o640); err != nil {
return nil, err return nil, err
} }
digest := md5.Sum(data) digest := md5.Sum(data)
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{ attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path, MemberID: memberID, Drive: strings.ToLower(strings.TrimSpace(s.cfg.Driver)), Name: filepath.Base(name), MimeType: mimeType, Path: storedPath,
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]), URL: publicURL, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
}) })
if err != nil { if err != nil {
_ = os.Remove(path) s.remove(ctx, storedPath)
return nil, err return nil, err
} }
return attachment, nil return attachment, nil
} }
func (s *Service) store(ctx context.Context, fileName, mimeType string, data []byte) (string, string, error) {
if s.minio != nil {
objectName := path.Join(strings.Trim(s.cfg.Path, "/"), time.Now().UTC().Format("2006/01/02"), fileName)
_, err := s.minio.PutObject(ctx, s.cfg.Bucket, objectName, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: mimeType})
if err != nil {
return "", "", err
}
return objectName, minioPublicURL(s.cfg.Domain, s.cfg.Bucket, objectName), nil
}
if err := os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
return "", "", err
}
localPath := filepath.Join(s.cfg.Dir, fileName)
if err := os.WriteFile(localPath, data, 0o640); err != nil {
return "", "", err
}
return localPath, strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName, nil
}
func (s *Service) remove(ctx context.Context, storedPath string) {
if s.minio != nil {
_ = s.minio.RemoveObject(ctx, s.cfg.Bucket, storedPath, minio.RemoveObjectOptions{})
return
}
_ = os.Remove(storedPath)
}
func minioPublicURL(domain, bucket, objectName string) string {
base := strings.TrimRight(strings.TrimSpace(domain), "/")
segments := strings.Split(strings.Trim(objectName, "/"), "/")
for i := range segments {
segments[i] = url.PathEscape(segments[i])
}
return base + "/" + url.PathEscape(bucket) + "/" + strings.Join(segments, "/")
}
+11
View File
@@ -0,0 +1,11 @@
package attachments
import "testing"
func TestMinioPublicURL(t *testing.T) {
got := minioPublicURL("https://minio.example.com/", "bucket", "images/2026/10/17/a b.jpg")
want := "https://minio.example.com/bucket/images/2026/10/17/a%20b.jpg"
if got != want {
t.Fatalf("minioPublicURL() = %q, want %q", got, want)
}
}
+1 -5
View File
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
return &Authenticator{cfg: cfg, redis: redisClient} return &Authenticator{cfg: cfg, redis: redisClient}
} }
func (a *Authenticator) DefaultTenantID() int64 {
return a.cfg.DefaultTenantID
}
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) { func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
if err := a.ValidateAPIKey(req); err != nil { if err := a.ValidateAPIKey(req); err != nil {
return nil, err return nil, err
} }
if a.cfg.AllowDevHeaderAuth { if a.cfg.AllowDevHeaderAuth {
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 { if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID, return &Principal{TenantID: 0, MemberID: memberID,
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
} }
} }
+88 -28
View File
@@ -37,8 +37,17 @@ type IMConfig struct {
} }
type UploadConfig struct { type UploadConfig struct {
Driver string `json:"driver"`
Dir string `json:"dir"` Dir string `json:"dir"`
PublicPath string `json:"public_path"` PublicPath string `json:"public_path"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Endpoint string `json:"endpoint"`
Secure bool `json:"secure"`
Path string `json:"path"`
Bucket string `json:"bucket"`
Domain string `json:"domain"`
Region string `json:"region"`
} }
type MySQLConfig struct { type MySQLConfig struct {
@@ -55,15 +64,15 @@ type RedisConfig struct {
} }
type AuthConfig struct { type AuthConfig struct {
APIKeys []string `json:"api_keys"` APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"` SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"` SessionTTLSeconds int `json:"session_ttl_seconds"`
DefaultTenantID int64 `json:"default_tenant_id"` DataEncryptionKey string `json:"data_encryption_key"`
DataEncryptionKey string `json:"data_encryption_key"` LookupHMACKey string `json:"lookup_hmac_key"`
LookupHMACKey string `json:"lookup_hmac_key"` FinanceMasterKey string `json:"finance_master_key"`
FinanceMasterKey string `json:"finance_master_key"` FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"` RequiredAPIKey bool `json:"required_api_key"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -77,7 +86,11 @@ func Load() (*Config, error) {
return nil, err return nil, err
} }
} }
configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey)
applyEnv(cfg) applyEnv(cfg)
if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) {
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey)
}
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
return nil, err return nil, err
} }
@@ -97,14 +110,13 @@ func defaultConfig() *Config {
Auth: AuthConfig{ Auth: AuthConfig{
SessionKeyPattern: "olons_server:session:token:%s", SessionKeyPattern: "olons_server:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60, SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
RequiredAPIKey: true, RequiredAPIKey: true,
}, },
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"}, Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
IM: IMConfig{ IM: IMConfig{
BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im", BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im",
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file",
TenantCode: "allrich", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS", TenantCode: "default", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
InternalService: "olons-server", Timeout: 8 * time.Second, InternalService: "olons-server", Timeout: 8 * time.Second,
}, },
ReadTimeout: 8 * time.Second, ReadTimeout: 8 * time.Second,
@@ -124,14 +136,15 @@ func applyEnv(cfg *Config) {
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB") setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN") setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS") setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY") setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY") setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY") setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_FINANCE_LEGACY_MASTER_KEYS")); keys != "" {
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, splitCSV(keys)...)
}
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH") setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY") setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR") ApplyUploadEnv(&cfg.Upload)
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL") setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL")
setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL") setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL")
setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL") setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL")
@@ -169,11 +182,8 @@ func (c *Config) validate() error {
if c.Auth.SessionTTLSeconds <= 0 { if c.Auth.SessionTTLSeconds <= 0 {
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60 c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
} }
if c.Auth.DefaultTenantID < 0 { if err := ValidateUpload(c.Upload); err != nil {
return errors.New("default tenant id must not be negative") return err
}
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
return errors.New("upload config is invalid")
} }
if c.IM.Timeout <= 0 { if c.IM.Timeout <= 0 {
c.IM.Timeout = 8 * time.Second c.IM.Timeout = 8 * time.Second
@@ -181,6 +191,64 @@ func (c *Config) validate() error {
return nil return nil
} }
func ApplyUploadSystemConfig(cfg *UploadConfig, values map[string]string) {
if cfg == nil {
return
}
setMapString(&cfg.Driver, values, "uploadDrive")
setMapString(&cfg.AccessKey, values, "uploadMinioAccessKey")
setMapString(&cfg.SecretKey, values, "uploadMinioSecretKey")
setMapString(&cfg.Endpoint, values, "uploadMinioEndpoint")
setMapString(&cfg.Path, values, "uploadMinioPath")
setMapString(&cfg.Bucket, values, "uploadMinioBucket")
setMapString(&cfg.Domain, values, "uploadMinioDomain")
if value := strings.TrimSpace(values["uploadMinioUseSSL"]); value != "" {
cfg.Secure = value == "1" || strings.EqualFold(value, "true")
}
}
func ApplyUploadEnv(cfg *UploadConfig) {
if cfg == nil {
return
}
setString(&cfg.Driver, "OLONS_SERVER_UPLOAD_DRIVER")
setString(&cfg.Dir, "OLONS_SERVER_UPLOAD_DIR")
setString(&cfg.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
setString(&cfg.AccessKey, "OLONS_SERVER_MINIO_ACCESS_KEY")
setString(&cfg.SecretKey, "OLONS_SERVER_MINIO_SECRET_KEY")
setString(&cfg.Endpoint, "OLONS_SERVER_MINIO_ENDPOINT")
setString(&cfg.Path, "OLONS_SERVER_MINIO_PATH")
setString(&cfg.Bucket, "OLONS_SERVER_MINIO_BUCKET")
setString(&cfg.Domain, "OLONS_SERVER_MINIO_DOMAIN")
setString(&cfg.Region, "OLONS_SERVER_MINIO_REGION")
if value := strings.TrimSpace(os.Getenv("OLONS_SERVER_MINIO_SECURE")); value != "" {
cfg.Secure = value == "1" || strings.EqualFold(value, "true")
}
}
func ValidateUpload(cfg UploadConfig) error {
switch strings.ToLower(strings.TrimSpace(cfg.Driver)) {
case "local":
if strings.TrimSpace(cfg.Dir) == "" || !strings.HasPrefix(cfg.PublicPath, "/") {
return errors.New("local upload config is invalid")
}
case "minio":
if strings.TrimSpace(cfg.AccessKey) == "" || strings.TrimSpace(cfg.SecretKey) == "" ||
strings.TrimSpace(cfg.Endpoint) == "" || strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.Domain) == "" {
return errors.New("minio upload config is incomplete")
}
default:
return errors.New("upload driver is unsupported")
}
return nil
}
func setMapString(target *string, values map[string]string, key string) {
if value := strings.TrimSpace(values[key]); value != "" {
*target = value
}
}
func setString(target *string, key string) { func setString(target *string, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" { if value := strings.TrimSpace(os.Getenv(key)); value != "" {
*target = value *target = value
@@ -195,14 +263,6 @@ func setInt(target *int, key string) {
} }
} }
func setInt64(target *int64, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
*target = parsed
}
}
}
func setBool(target *bool, key string) { func setBool(target *bool, key string) {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on": case "1", "true", "yes", "on":
+51 -3
View File
@@ -1,14 +1,18 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) { func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
cfg := defaultConfig() cfg := defaultConfig()
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Auth.DefaultTenantID != 0 { if cfg.IM.TenantCode != "default" {
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID) t.Fatalf("unexpected default IM tenant: %q", cfg.IM.TenantCode)
} }
} }
@@ -19,3 +23,47 @@ func TestConfigRejectsOtherDatabase(t *testing.T) {
t.Fatal("expected non-olons_admin database to be rejected") t.Fatal("expected non-olons_admin database to be rejected")
} }
} }
func TestLoadPreservesConfiguredFinanceKeyAsLegacyWhenEnvironmentOverridesIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
data := []byte(`{"auth":{"finance_master_key":"legacy-finance-master-key-with-at-least-32-characters"}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OLONS_SERVER_CONFIG", path)
t.Setenv("OLONS_SERVER_FINANCE_MASTER_KEY", "current-finance-master-key-with-at-least-32-characters")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if len(cfg.Auth.FinanceLegacyMasterKeys) != 1 ||
cfg.Auth.FinanceLegacyMasterKeys[0] != "legacy-finance-master-key-with-at-least-32-characters" {
t.Fatalf("configured finance key was not retained for historical data: %#v", cfg.Auth.FinanceLegacyMasterKeys)
}
}
func TestApplyUploadSystemConfig(t *testing.T) {
cfg := UploadConfig{Driver: "local", Region: "us-east-1"}
ApplyUploadSystemConfig(&cfg, map[string]string{
"uploadDrive": "minio",
"uploadMinioAccessKey": "access",
"uploadMinioSecretKey": "secret",
"uploadMinioEndpoint": "minio.example.com",
"uploadMinioUseSSL": "1",
"uploadMinioPath": "images/",
"uploadMinioBucket": "bucket",
"uploadMinioDomain": "https://cdn.example.com",
})
if err := ValidateUpload(cfg); err != nil {
t.Fatal(err)
}
if cfg.Driver != "minio" || !cfg.Secure || cfg.Bucket != "bucket" || cfg.Path != "images/" {
t.Fatalf("unexpected upload config: %+v", cfg)
}
}
func TestValidateUploadRejectsIncompleteMinio(t *testing.T) {
if err := ValidateUpload(UploadConfig{Driver: "minio"}); err == nil {
t.Fatal("expected incomplete minio config to be rejected")
}
}
+62 -61
View File
@@ -35,7 +35,6 @@ const (
var ( var (
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`) idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
) )
type Repository interface { type Repository interface {
@@ -59,19 +58,19 @@ type Repository interface {
} }
type Service struct { type Service struct {
repository Repository repository Repository
redis *redis.Client redis *redis.Client
aead cipher.AEAD aead cipher.AEAD
lookupKey []byte legacyAEADs []cipher.AEAD
ready bool lookupKey []byte
now func() time.Time ready bool
now func() time.Time
} }
type PaymentAccountInput struct { type PaymentAccountInput struct {
ID int64 `json:"id"` ID int64 `json:"id"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
HolderName string `json:"holder_name"` HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"` AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"` DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
@@ -84,7 +83,6 @@ type PaymentAccount struct {
BankCode string `json:"bank_code"` BankCode string `json:"bank_code"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"` HolderMask string `json:"holder_mask"`
IdentityNoMask string `json:"identity_no_mask"`
AccountNoMask string `json:"account_no_mask"` AccountNoMask string `json:"account_no_mask"`
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"` DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
@@ -99,7 +97,6 @@ type PaymentAccountDetail struct {
ID int64 `json:"id"` ID int64 `json:"id"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
HolderName string `json:"holder_name"` HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"` AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"` DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRURL string `json:"wechat_qr_url"` WechatQRURL string `json:"wechat_qr_url"`
@@ -266,20 +263,39 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
return service return service
} }
} }
block, err := aes.NewCipher(encryptionKey) aead, err := newAEAD(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return service return service
} }
service.aead = aead service.aead = aead
seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys))
for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys {
legacyMasterKey = strings.TrimSpace(legacyMasterKey)
if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey {
continue
}
if _, exists := seenLegacyKeys[legacyMasterKey]; exists {
continue
}
legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption"))
if legacyErr == nil {
service.legacyAEADs = append(service.legacyAEADs, legacyAEAD)
seenLegacyKeys[legacyMasterKey] = struct{}{}
}
}
service.lookupKey = lookupKey service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil service.ready = repository != nil && redisClient != nil
return service return service
} }
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func deriveKey(masterKey, purpose string) []byte { func deriveKey(masterKey, purpose string) []byte {
mac := hmac.New(sha256.New, []byte(masterKey)) mac := hmac.New(sha256.New, []byte(masterKey))
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1")) _, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
@@ -312,20 +328,13 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
items := make([]PaymentAccountDetail, 0, len(rows)) items := make([]PaymentAccountDetail, 0, len(rows))
for _, row := range rows { for _, row := range rows {
holderName, accountNo := row.HolderMask, row.AccountNoMask holderName, accountNo := row.HolderMask, row.AccountNoMask
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask digitalRMBCode := row.DigitalRMBCodeMask
masked := true masked := true
if s.ready { if s.ready {
decryptedHolder, holderErr := s.decrypt(row.HolderCipher) decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher) decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
if holderErr == nil && accountErr == nil { if holderErr == nil && accountErr == nil {
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
if row.IdentityNoCipher != "" {
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
identityNo = value
} else {
masked = true
}
}
if row.DigitalRMBCodeCipher != "" { if row.DigitalRMBCodeCipher != "" {
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil { if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
digitalRMBCode = value digitalRMBCode = value
@@ -337,7 +346,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
} }
items = append(items, PaymentAccountDetail{ items = append(items, PaymentAccountDetail{
ID: row.ID, BankName: row.BankName, HolderName: holderName, ID: row.ID, BankName: row.BankName, HolderName: holderName,
IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL, WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID, WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked, VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked,
@@ -386,10 +395,6 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
if err != nil { if err != nil {
return store.PaymentAccountCreation{}, err return store.PaymentAccountCreation{}, err
} }
identityNoCipher, err := s.encrypt(input.IdentityNo)
if err != nil {
return store.PaymentAccountCreation{}, err
}
accountCipher, err := s.encrypt(input.AccountNo) accountCipher, err := s.encrypt(input.AccountNo)
if err != nil { if err != nil {
return store.PaymentAccountCreation{}, err return store.PaymentAccountCreation{}, err
@@ -406,9 +411,8 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
return store.PaymentAccountCreation{ return store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName, TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo), AccountNoCipher: accountCipher,
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher, AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash, DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
DigitalRMBCodeMask: digitalRMBCodeMask, DigitalRMBCodeMask: digitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID, WechatQRAttachmentID: input.WechatQRAttachmentID,
@@ -454,13 +458,6 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
if err != nil { if err != nil {
return nil, store.ErrPaymentAccountInvalid return nil, store.ErrPaymentAccountInvalid
} }
identityNo := ""
if account.IdentityNoCipher != "" {
identityNo, err = s.decrypt(account.IdentityNoCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
}
digitalRMBCode := "" digitalRMBCode := ""
if account.DigitalRMBCodeCipher != "" { if account.DigitalRMBCodeCipher != "" {
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher) digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
@@ -471,7 +468,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
snapshot, err := json.Marshal(map[string]any{ snapshot, err := json.Marshal(map[string]any{
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode, "account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
"bank_name": account.BankName, "holder_name": holderName, "bank_name": account.BankName, "holder_name": holderName,
"identity_no": identityNo, "account_no": accountNo, "account_no": accountNo,
"digital_rmb_code": digitalRMBCode, "digital_rmb_code": digitalRMBCode,
"wechat_qr_attachment_id": account.WechatQRAttachmentID, "wechat_qr_attachment_id": account.WechatQRAttachmentID,
"alipay_qr_attachment_id": account.AlipayQRAttachmentID, "alipay_qr_attachment_id": account.AlipayQRAttachmentID,
@@ -803,7 +800,10 @@ func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) e
return err return err
} }
var config map[string]any var config map[string]any
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") { if json.Unmarshal(raw, &config) != nil {
config = map[string]any{}
}
if enabled, found := findBoolValue(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"); found && !enabled {
return store.ErrWithdrawalDisabled return store.ErrWithdrawalDisabled
} }
return nil return nil
@@ -846,10 +846,9 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
func normalizePaymentAccount(input *PaymentAccountInput) error { func normalizePaymentAccount(input *PaymentAccountInput) error {
input.BankName = strings.TrimSpace(input.BankName) input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName) input.HolderName = strings.TrimSpace(input.HolderName)
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo)) input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode)) input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) || if !validHumanName(input.HolderName) ||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 || utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) || (input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
@@ -862,7 +861,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error {
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount { func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode, return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask, BankName: row.BankName, HolderMask: row.HolderMask,
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask, AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
WechatQRAttachmentID: row.WechatQRAttachmentID, WechatQRAttachmentID: row.WechatQRAttachmentID,
AlipayQRAttachmentID: row.AlipayQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
@@ -1039,11 +1038,17 @@ func (s *Service) decrypt(value string) (string, error) {
if err != nil || len(payload) <= s.aead.NonceSize() { if err != nil || len(payload) <= s.aead.NonceSize() {
return "", errors.New("encrypted value is invalid") return "", errors.New("encrypted value is invalid")
} }
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil) aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...)
if err != nil { for _, aead := range aeads {
return "", err if len(payload) <= aead.NonceSize() {
continue
}
plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil)
if decryptErr == nil {
return string(plain), nil
}
} }
return string(plain), nil return "", errors.New("encrypted value cannot be decrypted")
} }
func (s *Service) lookupHash(value string) string { func (s *Service) lookupHash(value string) string {
@@ -1113,13 +1118,6 @@ func maskAccount(value string) string {
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:]) return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
} }
func maskIdentityNo(value string) string {
if len(value) <= 8 {
return strings.Repeat("*", len(value))
}
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
}
func validHumanName(value string) bool { func validHumanName(value string) bool {
count := utf8.RuneCountInString(value) count := utf8.RuneCountInString(value)
if count < 2 || count > 64 { if count < 2 || count > 64 {
@@ -1142,25 +1140,28 @@ func allDigits(value string) bool {
return value != "" return value != ""
} }
func findBool(config map[string]any, keys ...string) bool { func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
for _, key := range keys { for _, key := range keys {
for candidate, value := range config { for candidate, value := range config {
if strings.EqualFold(candidate, key) { if strings.EqualFold(candidate, key) {
switch typed := value.(type) { switch typed := value.(type) {
case bool: case bool:
return typed return typed, true
case float64: case float64:
return typed == 1 return typed == 1, true
case string: case string:
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on"), true
} }
return false, true
} }
} }
} }
for _, value := range config { for _, value := range config {
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) { if nested, ok := value.(map[string]any); ok {
return true if result, found := findBoolValue(nested, keys...); found {
return result, true
}
} }
} }
return false return false, false
} }
+46 -13
View File
@@ -118,32 +118,38 @@ func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
} }
} }
func TestQuoteFailsClosed(t *testing.T) { func TestQuoteDefaultsToEnabledWithoutWithdrawalSwitch(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now} service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
if err := service.ensureWithdrawalEnabled(context.Background(), 0); err != nil {
t.Fatalf("missing switch should default to enabled, got %v", err)
}
}
func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":false}}`)}, ready: true, now: time.Now}
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"}) _, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
if !errors.Is(err, store.ErrWithdrawalDisabled) { if !errors.Is(err, store.ErrWithdrawalDisabled) {
t.Fatalf("expected disabled error, got %v", err) t.Fatalf("expected explicitly disabled withdrawal, got %v", err)
} }
} }
func TestNormalizePaymentAccount(t *testing.T) { func TestNormalizePaymentAccount(t *testing.T) {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234", AccountNo: "6222 0000-0000 1234",
DigitalRMBCode: "1234 5678", DigitalRMBCode: "1234 5678",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil { if err := normalizePaymentAccount(&input); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" || if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" ||
input.DigitalRMBCode != "12345678" || maskAccount(input.AccountNo) != "6222********1234" {
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
t.Fatalf("unexpected normalized input: %#v", input) t.Fatalf("unexpected normalized input: %#v", input)
} }
} }
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) { func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三", input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234", AccountNo: "6222000000001234",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9} WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil { if err := normalizePaymentAccount(&input); err != nil {
t.Fatalf("empty digital RMB code should be optional: %v", err) t.Fatalf("empty digital RMB code should be optional: %v", err)
@@ -160,11 +166,10 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
}) })
holder, _ := service.encrypt("张三") holder, _ := service.encrypt("张三")
identity, _ := service.encrypt("11010119900101123X")
account, _ := service.encrypt("6222000000001234") account, _ := service.encrypt("6222000000001234")
digital, _ := service.encrypt("123456") digital, _ := service.encrypt("123456")
repository.details = []store.PaymentAccount{{ repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity, ID: 7, BankName: "中国工商银行", HolderCipher: holder,
AccountNoCipher: account, DigitalRMBCodeCipher: digital, AccountNoCipher: account, DigitalRMBCodeCipher: digital,
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
@@ -173,7 +178,7 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" || if len(items) != 1 || items[0].HolderName != "张三" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" || items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" || items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 { items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
@@ -181,13 +186,38 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
} }
} }
func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) {
repository := &fakeRepository{}
legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters"
legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey})
holder, _ := legacyService.encrypt("张三")
account, _ := legacyService.encrypt("6222000000001234")
digital, _ := legacyService.encrypt("123456")
repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account,
DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
}}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters",
FinanceLegacyMasterKeys: []string{legacyMasterKey},
})
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" {
t.Fatalf("unexpected legacy account details: %#v", items)
}
}
func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) { func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) {
repository := &paymentAccountRepository{} repository := &paymentAccountRepository{}
service := New(repository, &redis.Client{}, config.AuthConfig{ service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
}) })
result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{ result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{
ID: 7, BankName: "中国工商银行", HolderName: "李四", IdentityNo: "11010119900101123X", ID: 7, BankName: "中国工商银行", HolderName: "李四",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
}) })
if err != nil { if err != nil {
@@ -219,7 +249,7 @@ func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" || if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" ||
items[0].IdentityNo != "1101**********123X" || items[0].AccountNo != "6222********1234" || items[0].AccountNo != "6222********1234" ||
items[0].DigitalRMBCode != "12****78" { items[0].DigitalRMBCode != "12****78" {
t.Fatalf("unexpected masked fallback: %#v", items) t.Fatalf("unexpected masked fallback: %#v", items)
} }
@@ -231,7 +261,7 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters", FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
}) })
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{ _, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X", BankName: "中国工商银行", HolderName: "张三",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
}) })
if err != nil { if err != nil {
@@ -240,6 +270,9 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" { if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created) t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
} }
if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" {
t.Fatalf("identity number must no longer be collected: %#v", repository.created)
}
} }
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) { func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
+1 -1
View File
@@ -85,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
writePortalError(w, r, portal.ErrContentKeyInvalid) writePortalError(w, r, portal.ErrContentKeyInvalid)
return return
} }
tenantID := a.auth.DefaultTenantID() tenantID := int64(0)
if _, public := publicContentKeys[key]; !public { if _, public := publicContentKeys[key]; !public {
principal, ok := a.authenticateMember(w, r) principal, ok := a.authenticateMember(w, r)
if !ok { if !ok {
+36 -2
View File
@@ -25,6 +25,8 @@ import (
const maxResponseBody = 2 << 20 const maxResponseBody = 2 << 20
const defaultIMTenantCode = "default"
type Error struct { type Error struct {
Status int Status int
Code int Code int
@@ -105,6 +107,11 @@ type Conversation struct {
CanSend bool `json:"can_send"` CanSend bool `json:"can_send"`
} }
type DirectFriendResult struct {
Relationship string `json:"relationship"`
Topic string `json:"topic"`
}
func New(cfg config.IMConfig) *Client { func New(cfg config.IMConfig) *Client {
timeout := cfg.Timeout timeout := cfg.Timeout
if timeout <= 0 { if timeout <= 0 {
@@ -181,9 +188,13 @@ func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, p
} }
loginID := "login-" + requestID() loginID := "login-" + requestID()
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
loginType := 0
if strings.EqualFold(strings.TrimSpace(c.cfg.TenantCode), defaultIMTenantCode) {
loginType = 1
}
if err = conn.WriteJSON(map[string]any{"login": map[string]any{ if err = conn.WriteJSON(map[string]any{"login": map[string]any{
"id": loginID, "scheme": "basic", "secret": secret, "id": loginID, "scheme": "basic", "secret": secret,
"login_type": 0, "tenant_code": c.cfg.TenantCode, "login_type": loginType, "tenant_code": c.cfg.TenantCode,
}}); err != nil { }}); err != nil {
return nil, err return nil, err
} }
@@ -205,7 +216,7 @@ func (c *Client) ResetPassword(ctx context.Context, userID, password string) err
} }
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action" path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action"
return c.internalJSON(ctx, http.MethodPost, path, map[string]any{ return c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "Olons客服系统凭据同步", "event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "悦选客服系统凭据同步",
}, nil) }, nil)
} }
@@ -223,6 +234,18 @@ func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempot
return &out, err return &out, err
} }
// AddFriendDirect establishes a trusted server-side friendship without
// creating a user-visible friend request. The IM internal API authenticates
// this operation with the configured internal secret.
func (c *Client) AddFriendDirect(ctx context.Context, userID, peerUserID string) (*DirectFriendResult, error) {
var out DirectFriendResult
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/friends/direct"
err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"user_id": userID, "peer_user_id": peerUserID,
}, &out)
return &out, err
}
func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) { func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) {
var out Conversation var out Conversation
err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey, err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey,
@@ -382,6 +405,17 @@ func IsConflict(err error) bool {
return errors.As(err, &target) && target.Status == http.StatusConflict return errors.As(err, &target) && target.Status == http.StatusConflict
} }
func IsFriendRequestConflict(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusConflict &&
strings.EqualFold(strings.TrimSpace(target.Reason), "FRIEND_REQUEST_CONFLICT")
}
func IsPermissionDenied(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusForbidden
}
// IsAuthenticationFailed reports whether the IM server rejected user // IsAuthenticationFailed reports whether the IM server rejected user
// credentials. Callers may use this to repair credentials which have drifted // credentials. Callers may use this to repair credentials which have drifted
// from the local encrypted copy, while leaving transport failures untouched. // from the local encrypted copy, while leaving transport failures untouched.
+73
View File
@@ -52,6 +52,46 @@ func TestCreateUserAcceptsNumericIDAndSignsRequest(t *testing.T) {
} }
} }
func TestAddFriendDirectUsesSignedInternalAPI(t *testing.T) {
const secret = "internal-secret"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.Method != http.MethodPost || r.URL.Path != "/internal/admin-query/tenants/default/friends/direct" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("X-Service-Name") != "olons-server" {
t.Fatal("missing service header")
}
canonical := strings.Join([]string{r.Method, r.URL.RequestURI(), hashHex(body),
r.Header.Get("X-Timestamp"), r.Header.Get("X-Nonce")}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("X-Signature") != hex.EncodeToString(mac.Sum(nil)) {
t.Fatal("invalid signature")
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload["user_id"] != "usr_member" || payload["peer_user_id"] != "usr_agent" {
t.Fatalf("unexpected direct friendship payload: %+v", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"relationship":"friend","topic":"p2pAB"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, TenantCode: "default",
InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
result, err := client.AddFriendDirect(context.Background(), "usr_member", "usr_agent")
if err != nil {
t.Fatal(err)
}
if result.Relationship != "friend" || result.Topic != "p2pAB" {
t.Fatalf("unexpected direct friendship result: %+v", result)
}
}
func TestProductRequestHeaders(t *testing.T) { func TestProductRequestHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" { if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" {
@@ -120,6 +160,39 @@ func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
} }
} }
func TestLoginBasicUsesPersonalTypeForDefaultTenant(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var packet map[string]map[string]any
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
login := packet["login"]
if login["login_type"] != float64(1) || login["tenant_code"] != defaultIMTenantCode {
t.Fatalf("unexpected login: %#v", login)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: defaultIMTenantCode,
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
if _, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web"); err != nil {
t.Fatal(err)
}
}
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) { func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
+31 -21
View File
@@ -110,11 +110,11 @@ type MemberLevel struct {
} }
type LeaderboardEntry struct { type LeaderboardEntry struct {
Rank int `json:"rank"` Rank int `json:"rank"`
Username string `json:"username"` Username string `json:"username"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
CompletedCount int64 `json:"completed_count"` ClaimedPoints int64 `json:"claimed_points"`
Status string `json:"status"` Status string `json:"status"`
} }
type CustomerService struct { type CustomerService struct {
@@ -287,7 +287,7 @@ func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]Leaderboar
items := make([]LeaderboardEntry, 0, len(rows)) items := make([]LeaderboardEntry, 0, len(rows))
for index, row := range rows { for index, row := range rows {
items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username), items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username),
AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"}) AvatarURL: row.AvatarURL, ClaimedPoints: row.ClaimedPoints, Status: "领取成功"})
} }
return items, nil return items, nil
} }
@@ -328,30 +328,40 @@ func maskedUsername(value string) string {
func parseFeatures(raw json.RawMessage) Features { func parseFeatures(raw json.RawMessage) Features {
var config map[string]any var config map[string]any
if json.Unmarshal(raw, &config) != nil { if json.Unmarshal(raw, &config) != nil {
return Features{} config = map[string]any{}
} }
return Features{ return Features{
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"), TaskEnabled: findBoolDefault(config, true, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"), RechargeEnabled: findBoolDefault(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), WithdrawalEnabled: findBoolDefault(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), ManualMallEnabled: findBoolDefault(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
} }
} }
func findBool(config map[string]any, keys ...string) bool { func findBoolDefault(config map[string]any, defaultValue bool, keys ...string) bool {
for _, key := range keys { value, found := findBoolValue(config, keys...)
if value, ok := config[key]; ok { if !found {
return boolValue(value) return defaultValue
}
} }
if nested, ok := config["features"].(map[string]any); ok { return value
for _, key := range keys { }
if value, found := nested[key]; found {
return boolValue(value) func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
for candidate, value := range config {
if strings.EqualFold(candidate, key) {
return boolValue(value), true
} }
} }
} }
return false for _, value := range config {
if nested, ok := value.(map[string]any); ok {
if result, found := findBoolValue(nested, keys...); found {
return result, true
}
}
}
return false, false
} }
func boolValue(value any) bool { func boolValue(value any) bool {
+15 -3
View File
@@ -77,6 +77,18 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
} }
} }
func TestParseFeaturesDefaultsMissingSwitchesToEnabled(t *testing.T) {
features := parseFeatures(json.RawMessage(`{}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
t.Fatalf("missing switches should default to enabled: %+v", features)
}
features = parseFeatures(json.RawMessage(`{"features":{"task_enabled":false,"withdrawal_enabled":false}}`))
if features.TaskEnabled || features.WithdrawalEnabled || !features.RechargeEnabled || !features.ManualMallEnabled {
t.Fatalf("explicitly disabled switches must remain disabled: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) { func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{ service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"}, {ID: 1, TargetType: 2, TargetValue: "/receive"},
@@ -153,15 +165,15 @@ func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) { func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) {
service := New(fakeRepository{leaders: []store.LeaderboardMember{ service := New(fakeRepository{leaders: []store.LeaderboardMember{
{MemberID: 7, Username: "风轻云淡", CompletedCount: 12}, {MemberID: 7, Username: "风轻云淡", ClaimedPoints: 125800},
{MemberID: 8, Username: "李明", CompletedCount: 9}, {MemberID: 8, Username: "李明", ClaimedPoints: 96000},
}}) }})
items, err := service.Leaderboard(context.Background(), 1) items, err := service.Leaderboard(context.Background(), 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" || if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" ||
items[1].Username != "李*" || items[1].Status != "领取成功" { items[0].ClaimedPoints != 125800 || items[1].Username != "李*" || items[1].Status != "领取成功" {
t.Fatalf("unexpected leaderboard: %+v", items) t.Fatalf("unexpected leaderboard: %+v", items)
} }
} }
+4 -4
View File
@@ -95,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
} }
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) { func (s *Store) AccountByMobile(ctx context.Context, mobile string) (*Account, error) {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return nil, errors.New("store is unavailable") return nil, errors.New("store is unavailable")
} }
return accountByMobile(ctx, s.db, tenantID, mobile) return accountByMobile(ctx, s.db, mobile)
} }
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error { func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
@@ -112,11 +112,11 @@ type queryer interface {
GetContext(context.Context, any, string, ...any) error GetContext(context.Context, any, string, ...any) error
} }
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) { func accountByMobile(ctx context.Context, db queryer, mobile string) (*Account, error) {
var row Account var row Account
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile, err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
password_hash, invite_code, member_status, risk_status AS member_category password_hash, invite_code, member_status, risk_status AS member_category
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile) FROM hg_app_member WHERE mobile=? AND deleted_at IS NULL LIMIT 1`, mobile)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"context"
"strings"
"testing"
)
type accountQueryRecorder struct {
query string
args []any
}
func (r *accountQueryRecorder) GetContext(_ context.Context, dest any, query string, args ...any) error {
r.query = query
r.args = args
account := dest.(*Account)
*account = Account{MemberID: 9, Mobile: "13311111111", MemberStatus: memberStatusActive}
return nil
}
func TestAccountByMobileDoesNotFilterTenant(t *testing.T) {
recorder := &accountQueryRecorder{}
account, err := accountByMobile(context.Background(), recorder, "13311111111")
if err != nil {
t.Fatal(err)
}
if account.MemberID != 9 {
t.Fatalf("MemberID = %d, want 9", account.MemberID)
}
if strings.Contains(strings.ToLower(recorder.query), "tenant_id=?") {
t.Fatalf("query must not filter tenant: %s", recorder.query)
}
if len(recorder.args) != 1 || recorder.args[0] != "13311111111" {
t.Fatalf("query args = %#v", recorder.args)
}
}
+3 -2
View File
@@ -4,6 +4,7 @@ import "context"
type AttachmentCreation struct { type AttachmentCreation struct {
MemberID int64 MemberID int64
Drive string
Name string Name string
MimeType string MimeType string
Path string Path string
@@ -21,8 +22,8 @@ type Attachment struct {
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) { func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at) (app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`, VALUES('olons_server',?,0,?,?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5) input.MemberID, input.Drive, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+1 -2
View File
@@ -73,8 +73,7 @@ func manualProductSelectSQL() string {
COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended, COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended,
COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at, COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at,
COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at, COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at,
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id p.claimed_count,
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_product p JOIN hg_product_category c FROM hg_product p JOIN hg_product_category c
+7 -7
View File
@@ -68,10 +68,10 @@ type TaskSummary struct {
} }
type LeaderboardMember struct { type LeaderboardMember struct {
MemberID int64 `db:"member_id"` MemberID int64 `db:"member_id"`
Username string `db:"username"` Username string `db:"username"`
AvatarURL string `db:"avatar_url"` AvatarURL string `db:"avatar_url"`
CompletedCount int64 `db:"completed_count"` ClaimedPoints int64 `db:"claimed_points"`
} }
type CustomerService struct { type CustomerService struct {
@@ -250,15 +250,15 @@ func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int)
} }
rows := make([]LeaderboardMember, 0) rows := make([]LeaderboardMember, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username, err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username,
COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count COALESCE(a.file_url,'') AS avatar_url,COALESCE(SUM(o.total_amount),0) AS claimed_points
FROM hg_app_member m FROM hg_app_member m
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1 LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id
AND o.order_type=2 AND o.order_status=4 AND o.order_type=2 AND o.order_status=4
WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL WHERE m.tenant_id=? AND m.member_status=1 AND m.is_test_account=2 AND m.deleted_at IS NULL
GROUP BY m.id,m.username,a.file_url GROUP BY m.id,m.username,a.file_url
ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit) ORDER BY claimed_points DESC,m.id ASC LIMIT ?`, tenantID, limit)
return rows, err return rows, err
} }
+5 -4
View File
@@ -37,13 +37,14 @@ func (s *Store) CustomerServiceByID(ctx context.Context, tenantID, serviceID int
return &row, err return &row, err
} }
func (s *Store) MemberIMIdentity(ctx context.Context, tenantID, memberID int64) (*MemberIMIdentity, error) { func (s *Store) MemberIMIdentityForTenant(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*MemberIMIdentity, error) {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return nil, errors.New("store is unavailable") return nil, errors.New("store is unavailable")
} }
var row MemberIMIdentity var row MemberIMIdentity
err := s.db.GetContext(ctx, &row, `SELECT id,tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher err := s.db.GetContext(ctx, &row, `SELECT id,tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher
FROM hg_app_member_im_identity WHERE tenant_id=? AND member_id=? LIMIT 1`, tenantID, memberID) FROM hg_app_member_im_identity WHERE tenant_id=? AND member_id=? AND im_tenant_code=? LIMIT 1`,
tenantID, memberID, imTenantCode)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound return nil, ErrResourceNotFound
} }
@@ -77,10 +78,10 @@ func (s *Store) SaveMemberIMIdentity(ctx context.Context, identity *MemberIMIden
im_password_cipher=VALUES(im_password_cipher),sync_status=1,last_error='',updated_at=UTC_TIMESTAMP(3)`, im_password_cipher=VALUES(im_password_cipher),sync_status=1,last_error='',updated_at=UTC_TIMESTAMP(3)`,
identity.TenantID, identity.MemberID, identity.IMTenantCode, identity.IMUserID, identity.IMUsername, identity.IMPasswordCipher) identity.TenantID, identity.MemberID, identity.IMTenantCode, identity.IMUserID, identity.IMUsername, identity.IMPasswordCipher)
if err == nil { if err == nil {
return s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID) return s.MemberIMIdentityForTenant(ctx, identity.TenantID, identity.MemberID, identity.IMTenantCode)
} }
// A concurrent request may have completed the same deterministic mapping. // A concurrent request may have completed the same deterministic mapping.
existing, findErr := s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID) existing, findErr := s.MemberIMIdentityForTenant(ctx, identity.TenantID, identity.MemberID, identity.IMTenantCode)
if findErr == nil { if findErr == nil {
return existing, nil return existing, nil
} }
+1 -1
View File
@@ -205,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if wallet.Status != 1 { if wallet.Status != 1 {
return nil, ErrAccountRestricted return nil, ErrAccountRestricted
} }
if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) { if wallet.AvailableAmount < input.MinimumAmount {
return nil, ErrInsufficientBalance return nil, ErrInsufficientBalance
} }
var pending int64 var pending int64
+20
View File
@@ -0,0 +1,20 @@
package store
import "context"
func (s *Store) UploadConfigValues(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT `key`, `value` FROM hg_sys_config WHERE `group`='upload' AND status=1")
if err != nil {
return nil, err
}
defer rows.Close()
values := make(map[string]string)
for rows.Next() {
var key, value string
if err = rows.Scan(&key, &value); err != nil {
return nil, err
}
values[key] = value
}
return values, rows.Err()
}
+29 -27
View File
@@ -20,7 +20,7 @@ import (
type Repository interface { type Repository interface {
CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error)
MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error) MemberIMIdentityForTenant(context.Context, int64, int64, string) (*store.MemberIMIdentity, error)
MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error)
SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error)
} }
@@ -33,7 +33,7 @@ type IM interface {
LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error) LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error)
ResetPassword(context.Context, string, string) error ResetPassword(context.Context, string, string) error
UserProfile(context.Context, string, string) (*imclient.UserProfile, error) UserProfile(context.Context, string, string) (*imclient.UserProfile, error)
CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error)
EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error)
} }
@@ -70,6 +70,12 @@ type Session struct {
Agent Agent `json:"agent"` Agent Agent `json:"agent"`
} }
const (
publicIMAPIPath = "/v1/im"
publicIMWebSocketPath = "/v0/channels"
publicIMFilePath = "/v0/file"
)
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service { func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)} return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
} }
@@ -120,32 +126,24 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID
return nil, ErrIMUnavailable return nil, ErrIMUnavailable
} }
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID) profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
relationship := ""
if err != nil { if err != nil {
return nil, fmt.Errorf("query customer service relationship: %w", err) if !imclient.IsPermissionDenied(err) {
return nil, fmt.Errorf("query customer service relationship: %w", err)
}
relationship = "unknown"
} else {
relationship = strings.TrimSpace(profile.Relationship)
} }
relationship := strings.TrimSpace(profile.Relationship)
switch relationship { switch relationship {
case "friend": case "friend":
case "none", "": case "none", "", "pending_inbound", "pending_outbound", "pending", "unknown":
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID) if _, err = s.im.AddFriendDirect(ctx, identity.IMUserID, agent.IMUserID); err != nil {
request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key) if imclient.IsPermissionDenied(err) || imclient.IsConflict(err) {
if requestErr != nil && !imclient.IsConflict(requestErr) { return nil, ErrRelationDenied
return nil, fmt.Errorf("create customer service friend request: %w", requestErr)
}
if requestErr != nil {
profile, err = s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
if err != nil {
return nil, fmt.Errorf("refresh customer service relationship: %w", err)
} }
relationship = strings.TrimSpace(profile.Relationship) return nil, fmt.Errorf("add customer service as friend: %w", err)
} else {
relationship = strings.TrimSpace(request.Status)
} }
if relationship != "accepted" && relationship != "friend" {
return nil, ErrRelationPending
}
case "pending_inbound", "pending_outbound", "pending":
return nil, ErrRelationPending
default: default:
return nil, ErrRelationDenied return nil, ErrRelationDenied
} }
@@ -158,8 +156,8 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID
return nil, ErrRelationDenied return nil, ErrRelationDenied
} }
return &Session{ return &Session{
TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL, TenantCode: cfg.TenantCode, APIBaseURL: publicIMAPIPath, WebSocketURL: publicIMWebSocketPath,
FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt, FileURL: publicIMFilePath, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend", CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL, Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
OnlineStatus: customerServiceStatus(agent.OnlineStatus)}, OnlineStatus: customerServiceStatus(agent.OnlineStatus)},
@@ -167,9 +165,9 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID
} }
func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) { func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
identity, err := s.repository.MemberIMIdentity(ctx, tenantID, memberID) identity, err := s.repository.MemberIMIdentityForTenant(ctx, tenantID, memberID, imTenantCode)
if err == nil { if err == nil {
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || identity.IMUserID == "" || identity.IMUsername == "" { if identity.IMUserID == "" || identity.IMUsername == "" {
return nil, ErrIMUnavailable return nil, ErrIMUnavailable
} }
if identity.IMPasswordCipher != "" { if identity.IMPasswordCipher != "" {
@@ -180,6 +178,10 @@ func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64,
if !errors.Is(err, store.ErrResourceNotFound) { if !errors.Is(err, store.ErrResourceNotFound) {
return nil, err return nil, err
} }
return s.createIdentity(ctx, tenantID, memberID, imTenantCode)
}
func (s *Service) createIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID) member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -214,7 +216,7 @@ func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64,
if err != nil { if err != nil {
return nil, err return nil, err
} }
identity = &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode, identity := &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode,
IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext} IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext}
return s.repository.SaveMemberIMIdentity(ctx, identity) return s.repository.SaveMemberIMIdentity(ctx, identity)
} }
+124 -16
View File
@@ -2,6 +2,7 @@ package support
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -12,28 +13,44 @@ import (
) )
type repositoryStub struct { type repositoryStub struct {
agent *store.CustomerService agent *store.CustomerService
identity *store.MemberIMIdentity identity *store.MemberIMIdentity
profile *store.MemberIMProfile
requestedIMTenantCode string
} }
func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) { func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) {
return r.agent, nil return r.agent, nil
} }
func (r *repositoryStub) MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error) { func (r *repositoryStub) MemberIMIdentityForTenant(_ context.Context, _, _ int64, imTenantCode string) (*store.MemberIMIdentity, error) {
r.requestedIMTenantCode = imTenantCode
if r.identity == nil || !strings.EqualFold(r.identity.IMTenantCode, imTenantCode) {
return nil, store.ErrResourceNotFound
}
return r.identity, nil return r.identity, nil
} }
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) { func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
return nil, store.ErrResourceNotFound if r.profile == nil {
return nil, store.ErrResourceNotFound
}
return r.profile, nil
} }
func (r *repositoryStub) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) { func (r *repositoryStub) SaveMemberIMIdentity(_ context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
copy := *identity
r.identity = &copy
return r.identity, nil return r.identity, nil
} }
type imStub struct { type imStub struct {
friendRequests int directFriendAdds int
loginAttempts int loginAttempts int
resetPasswords int resetPasswords int
loginError error loginError error
profileError error
friendError error
conversation *imclient.Conversation
conversationErr error
ensureP2PCalls int
} }
func (i *imStub) Ready() bool { return true } func (i *imStub) Ready() bool { return true }
@@ -41,8 +58,8 @@ func (i *imStub) Config() config.IMConfig {
return config.IMConfig{TenantCode: "allrich", HTTPAPIURL: "https://api.118im.com/v1/im", return config.IMConfig{TenantCode: "allrich", HTTPAPIURL: "https://api.118im.com/v1/im",
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", APIKey: "key"} WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", APIKey: "key"}
} }
func (i *imStub) CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error) { func (i *imStub) CreateUser(_ context.Context, username, nickname, _ string) (*imclient.CreatedUser, error) {
return nil, nil return &imclient.CreatedUser{UserID: imclient.FlexibleID("1001"), Username: username, Nickname: nickname}, nil
} }
func (i *imStub) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) { func (i *imStub) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) {
return nil, nil return nil, nil
@@ -61,13 +78,26 @@ func (i *imStub) ResetPassword(context.Context, string, string) error {
return nil return nil
} }
func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserProfile, error) { func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserProfile, error) {
if i.profileError != nil {
return nil, i.profileError
}
return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil
} }
func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) { func (i *imStub) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) {
i.friendRequests++ i.directFriendAdds++
return &imclient.FriendRequest{RequestID: "frq_1", Status: "accepted"}, nil if i.friendError != nil {
return nil, i.friendError
}
return &imclient.DirectFriendResult{Relationship: "friend", Topic: "p2p-topic"}, nil
} }
func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) { func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
i.ensureP2PCalls++
if i.conversationErr != nil {
return nil, i.conversationErr
}
if i.conversation != nil {
return i.conversation, nil
}
return &imclient.Conversation{Topic: "p2p-topic", Relationship: "friend", CanSend: true}, nil return &imclient.Conversation{Topic: "p2p-topic", Relationship: "friend", CanSend: true}, nil
} }
@@ -86,8 +116,48 @@ func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) {
if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" { if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" {
t.Fatalf("unexpected session: %+v", session) t.Fatalf("unexpected session: %+v", session)
} }
if im.friendRequests != 1 { if im.directFriendAdds != 1 {
t.Fatalf("friend requests = %d, want 1", im.friendRequests) t.Fatalf("direct friend adds = %d, want 1", im.directFriendAdds)
}
}
func TestOpenSessionReturnsSameOriginEndpoints(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if err != nil {
t.Fatal(err)
}
if session.APIBaseURL != "/v1/im" || session.WebSocketURL != "/v0/channels" || session.FileURL != "/v0/file" {
t.Fatalf("browser endpoints must be same-origin paths: %+v", session)
}
}
func TestOpenSessionDeniesWhenDirectFriendshipIsBlocked(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{
profileError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
friendError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
conversationErr: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
_, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if !errors.Is(err, ErrRelationDenied) {
t.Fatalf("error = %v, want ErrRelationDenied", err)
}
if im.ensureP2PCalls != 0 || im.directFriendAdds != 1 {
t.Fatalf("ensure p2p calls=%d direct friend adds=%d, want 0 and 1", im.ensureP2PCalls, im.directFriendAdds)
} }
} }
@@ -123,3 +193,41 @@ func TestMemberIMUsernameMatchesIMConstraint(t *testing.T) {
} }
} }
} }
func TestEnsureIdentityReprovisionsMemberWhenIMTenantChanges(t *testing.T) {
repository := &repositoryStub{
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_old", IMUsername: "olduser"},
profile: &store.MemberIMProfile{MemberID: 9, Username: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
identity, err := service.ensureIdentity(context.Background(), 0, 9, "default")
if err != nil {
t.Fatal(err)
}
if identity.IMTenantCode != "default" || identity.IMUserID != "usr_member" || identity.IMUsername != memberIMUsername(0, 9, "default") {
t.Fatalf("unexpected migrated identity: %+v", identity)
}
if im.loginAttempts != 1 {
t.Fatalf("provisioning login attempts = %d, want 1", im.loginAttempts)
}
}
func TestEnsureIdentityQueriesTheActiveIMTenantMapping(t *testing.T) {
repository := &repositoryStub{
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_old", IMUsername: "olduser"},
profile: &store.MemberIMProfile{MemberID: 9, Username: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
identity, err := service.ensureIdentity(context.Background(), 0, 9, "default")
if err != nil {
t.Fatal(err)
}
if repository.requestedIMTenantCode != "default" {
t.Fatalf("queried IM tenant = %q, want default", repository.requestedIMTenantCode)
}
if identity.IMTenantCode != "default" {
t.Fatalf("identity tenant = %q, want default", identity.IMTenantCode)
}
}
+11 -49
View File
@@ -18,10 +18,11 @@ const (
defaultPageSize = 20 defaultPageSize = 20
maxPageSize = 100 maxPageSize = 100
defaultDeadlineMinute = 15 defaultDeadlineMinute = 15
minimumClaimAmount = 29900
businessDisplayStart = "09:00:00+08:00"
businessDisplayEnd = "21:00:00+08:00"
) )
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Repository interface { type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
@@ -158,8 +159,6 @@ type taskPolicy struct {
AutoDelay int AutoDelay int
AutoMinAmount int64 AutoMinAmount int64
AutoMaxAmount int64 AutoMaxAmount int64
Start string
End string
Deadline time.Duration Deadline time.Duration
} }
@@ -194,20 +193,18 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
} }
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount, result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID, TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"} BusinessStartAt: businessDisplayStart, BusinessEndAt: businessDisplayEnd}
switch { switch {
case !policy.Enabled: case !policy.Enabled:
result.reject("TASK_DISABLED", "任务功能未开启") result.reject("TASK_DISABLED", "任务功能未开启")
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
case row.MemberStatus != 1: case row.MemberStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限") result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case !row.HallEligible: case !row.HallEligible:
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅") result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
case row.WalletStatus != 1: case row.WalletStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限") result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount): case row.AvailableAmount < policy.MinimumAmount:
result.reject("INSUFFICIENT_BALANCE", "可用余额不足") result.reject("INSUFFICIENT_BALANCE", "可用余额不足299元")
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
case row.PendingOrderID > 0: case row.PendingOrderID > 0:
@@ -233,9 +230,6 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
if !policy.Enabled { if !policy.Enabled {
return nil, ErrTaskDisabled return nil, ErrTaskDisabled
} }
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
return nil, ErrOutsideBusinessHour
}
now := s.now().UTC() now := s.now().UTC()
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID, row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay, HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay,
@@ -379,14 +373,14 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
if minutes <= 0 || minutes > 24*60 { if minutes <= 0 || minutes > 24*60 {
minutes = defaultDeadlineMinute minutes = defaultDeadlineMinute
} }
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00") enabledValue := findValue(config, "task_enabled", "taskEnabled", "TASK")
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59") enabled := enabledValue == nil || boolValue(enabledValue)
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")), return taskPolicy{Enabled: enabled,
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))), MinimumAmount: minimumClaimAmount,
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))), AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))), AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))), AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil Deadline: time.Duration(minutes) * time.Minute}, nil
} }
func orderFromStore(row store.TaskOrder, detail bool) Order { func orderFromStore(row store.TaskOrder, detail bool) Order {
@@ -506,38 +500,6 @@ func submissionStatusText(status int) string {
return names[status] return names[status]
} }
func withinBusinessHours(now time.Time, start, end string) bool {
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
startSecond := parseClock(start)
endSecond := parseClock(end)
if startSecond <= endSecond {
return current >= startSecond && current <= endSecond
}
return current >= startSecond || current <= endSecond
}
func parseClock(value string) int {
parsed, err := time.Parse("15:04:05", value)
if err != nil {
return 0
}
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
}
func clockValue(value any, fallback string) string {
raw, ok := value.(string)
if !ok {
return fallback
}
raw = strings.TrimSpace(raw)
for _, layout := range []string{"15:04:05", "15:04"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed.Format("15:04:05")
}
}
return fallback
}
func findValue(config map[string]any, keys ...string) any { func findValue(config map[string]any, keys ...string) any {
for _, key := range keys { for _, key := range keys {
for candidate, value := range config { for candidate, value := range config {
+36 -28
View File
@@ -62,58 +62,66 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) (
return []map[string]any{}, nil return []map[string]any{}, nil
} }
func TestEligibilityAppliesPublishedPolicy(t *testing.T) { func TestEligibilityRequiresFixedMinimumBalance(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
result, err := service.Eligibility(context.Background(), 0, 10, 1) result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 { if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 29900 {
t.Fatalf("unexpected eligibility: %#v", result) t.Fatalf("unexpected eligibility: %#v", result)
} }
} }
func TestEligibilityAllowsManualDispatchBelowMinimumBalance(t *testing.T) { func TestEligibilityAlsoRequiresMinimumForManualDispatch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true}}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}} eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5}}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil {
t.Fatal(err)
}
if !result.Eligible {
t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result)
}
}
func TestEligibilityRejectsAutoDispatchWithZeroBalanceWhenMinimumIsZero(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
result, err := service.Eligibility(context.Background(), 0, 10, 1) result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" { if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" {
t.Fatalf("zero balance must not allow automatic claiming: %#v", result) t.Fatalf("manual dispatch must use the same minimum balance gate: %#v", result)
} }
} }
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { func TestEligibilityDoesNotEnforceDisplayedBusinessHours(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_business_start_at":"09:00","task_business_end_at":"21:00"}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29900, WalletStatus: 1, DailyLimit: 5}}
service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 23, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil {
t.Fatal(err)
}
if !result.Eligible || result.BusinessStartAt != "09:00:00+08:00" || result.BusinessEndAt != "21:00:00+08:00" {
t.Fatalf("business hours are display-only: %#v", result)
}
}
func TestClaimDefaultsToEnabledWithoutTaskSwitch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{}`)} repository := &fakeRepository{config: json.RawMessage(`{}`)}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
if _, err := service.Claim(context.Background(), 0, 10, 1); err != nil {
t.Fatalf("missing switch should default to enabled, got %v", err)
}
}
func TestClaimHonorsExplicitDisabledTaskSwitch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":false}}`)}
service := New(repository)
_, err := service.Claim(context.Background(), 0, 10, 1) _, err := service.Claim(context.Background(), 0, 10, 1)
if !errors.Is(err, ErrTaskDisabled) { if !errors.Is(err, ErrTaskDisabled) {
t.Fatalf("expected task disabled, got %v", err) t.Fatalf("expected explicitly disabled task, got %v", err)
} }
} }
@@ -128,7 +136,7 @@ func TestClaimBuildsMatchingOrder(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if result.Status != "matching" || repository.claimed.HallID != 2 || if result.Status != "matching" || repository.claimed.HallID != 2 ||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { repository.claimed.MinimumAmount != 29900 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
} }
} }
+25 -3
View File
@@ -21,19 +21,41 @@ class DeployWorkflowContractTest(unittest.TestCase):
self.assertIn("git.olns.shop/pro/olons_server", self.workflow) self.assertIn("git.olns.shop/pro/olons_server", self.workflow)
self.assertIn("docker push", self.workflow) self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow) self.assertIn(":pro", self.workflow)
self.assertIn("--build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party", self.workflow)
def test_production_image_enables_customer_service_widget(self):
dockerfile = (WORKFLOW.parents[2] / "Dockerfile").read_text(encoding="utf-8")
self.assertIn("ARG VITE_CUSTOMER_SERVICE_PROVIDER=", dockerfile)
self.assertIn("ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}", dockerfile)
def test_deploys_compose_service_with_pinned_host_key(self): def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow) self.assertIn("DEPLOY_SSH_KEY", self.workflow)
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow) self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
self.assertIn("StrictHostKeyChecking=yes", self.workflow) self.assertIn("StrictHostKeyChecking=yes", self.workflow)
self.assertIn("docker compose pull olons-server", self.workflow) self.assertIn("pull olons-server", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait olons-server", self.workflow) self.assertIn("up -d --no-deps --wait olons-server", self.workflow)
def test_removes_old_kubernetes_and_registry_targets(self): def test_removes_old_kubernetes_and_registry_targets(self):
self.assertNotIn("kubectl", self.workflow) self.assertNotIn("kubectl", self.workflow)
self.assertNotIn("KUBECONFIG", self.workflow) self.assertNotIn("KUBECONFIG", self.workflow)
self.assertNotIn("git.118im.com", self.workflow) self.assertNotIn("git.118im.com", self.workflow)
def test_uses_internal_im_endpoints_for_server_calls(self):
self.assertIn('"$OLONS_IM_BASE_URL" = "http://im-server:6060"', self.workflow)
self.assertIn('"$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"', self.workflow)
self.assertIn('"$OLONS_IM_WS_URL" = "ws://im-server:6060/v0/channels"', self.workflow)
self.assertIn('"$OLONS_IM_FILE_URL" = "http://im-server:6060/v0/file"', self.workflow)
self.assertNotIn("OLONS_IM_BASE_URL=https://im-api.yuexuan.shop", self.workflow)
def test_deploy_uses_managed_compose_environment_without_secret_overlay(self):
self.assertNotIn(".env.olons-server", self.workflow)
self.assertNotIn("compose.olons-server-secret.yaml", self.workflow)
self.assertNotIn("INTERNAL_API_HOTGO_SECRET", self.workflow)
self.assertNotIn("OLONS_IM_API_KEY=", self.workflow)
self.assertIn("docker compose pull olons-server", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait olons-server", self.workflow)
self.assertIn('test "$OLONS_IM_TENANT_CODE" = "default"', self.workflow)
self.assertIn('test "$OLONS_SERVER_DEFAULT_TENANT_ID" = "0"', self.workflow)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+4 -3
View File
@@ -9,9 +9,10 @@ class DeploymentContractTest(unittest.TestCase):
def test_workflow_targets_only_olons_server(self): def test_workflow_targets_only_olons_server(self):
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text() workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
self.assertIn("Deploy olons-server (k3s test cluster)", workflow) self.assertIn("Deploy olons-server production", workflow)
self.assertIn("git.118im.com/im_team/olons-server:", workflow) self.assertIn("git.olns.shop/pro/olons_server", workflow)
self.assertIn("deployment/olons-server -n olons-server-system", workflow) self.assertIn("pull olons-server", workflow)
self.assertIn("up -d --no-deps --wait olons-server", workflow)
self.assertNotIn("im-workbench", workflow) self.assertNotIn("im-workbench", workflow)
self.assertNotIn("workbench-system", workflow) self.assertNotIn("workbench-system", workflow)
+1 -1
View File
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/> />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<title>Olons</title> <title>悦选</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4 -2
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@vicons/ionicons5": "^0.12.0", "@vicons/ionicons5": "^0.12.0",
@@ -20,7 +21,8 @@
"@types/node": "^22.5.2", "@types/node": "^22.5.2",
"@vitejs/plugin-vue": "^5.1.3", "@vitejs/plugin-vue": "^5.1.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.2" "vite": "^5.4.2",
"vitest": "^2.1.9"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+311 -12
View File
@@ -13,7 +13,7 @@ importers:
version: 0.12.0 version: 0.12.0
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.7.7 version: 1.7.7(debug@4.4.3)
naive-ui: naive-ui:
specifier: ^2.44.1 specifier: ^2.44.1
version: 2.45.3(vue@3.4.38(typescript@5.5.4)) version: 2.45.3(vue@3.4.38(typescript@5.5.4))
@@ -39,6 +39,9 @@ importers:
vite: vite:
specifier: ^5.4.2 specifier: ^5.4.2
version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vitest:
specifier: ^2.1.9
version: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
packages: packages:
@@ -225,9 +228,6 @@ packages:
'@jridgewell/source-map@0.3.6': '@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -348,6 +348,35 @@ packages:
vite: ^5.0.0 vite: ^5.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
'@vitest/mocker@2.1.9':
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@2.1.9':
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
'@vitest/runner@2.1.9':
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
'@vitest/snapshot@2.1.9':
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
'@vitest/spy@2.1.9':
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@vue/compiler-core@3.4.38': '@vue/compiler-core@3.4.38':
resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==}
@@ -385,6 +414,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-validator@4.2.5: async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
@@ -397,6 +430,18 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -424,6 +469,19 @@ packages:
date-fns@4.4.0: date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
delayed-stream@1.0.0: delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -436,6 +494,9 @@ packages:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true hasBin: true
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.21.5: esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -444,9 +505,16 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
evtd@0.2.4: evtd@0.2.4:
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
follow-redirects@1.15.6: follow-redirects@1.15.6:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -495,9 +563,15 @@ packages:
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.11: magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@2.1.0: make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -515,6 +589,9 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
naive-ui@2.45.3: naive-ui@2.45.3:
resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==} resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -535,6 +612,13 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.0: picocolors@1.1.0:
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
@@ -570,6 +654,9 @@ packages:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.0: source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -581,6 +668,12 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
terser@5.31.6: terser@5.31.6:
resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==} resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -589,6 +682,24 @@ packages:
tinode-sdk@0.25.4: tinode-sdk@0.25.4:
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==} resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@1.2.0:
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
engines: {node: '>=14.0.0'}
tinyspy@3.0.2:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
to-fast-properties@2.0.0: to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -612,6 +723,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.11 vue: ^3.0.11
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite@5.4.2: vite@5.4.2:
resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==} resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -643,6 +759,31 @@ packages:
terser: terser:
optional: true optional: true
vitest@2.1.9:
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 2.1.9
'@vitest/ui': 2.1.9
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vooks@0.2.12: vooks@0.2.12:
resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
peerDependencies: peerDependencies:
@@ -666,6 +807,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.11 vue: ^3.0.11
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots: snapshots:
'@babel/helper-string-parser@7.24.8': {} '@babel/helper-string-parser@7.24.8': {}
@@ -780,10 +926,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
optional: true optional: true
'@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.5.5':
optional: true
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
dependencies: dependencies:
@@ -860,6 +1003,46 @@ snapshots:
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
'@vitest/pretty-format@2.1.9':
dependencies:
tinyrainbow: 1.2.0
'@vitest/runner@2.1.9':
dependencies:
'@vitest/utils': 2.1.9
pathe: 1.1.2
'@vitest/snapshot@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
magic-string: 0.30.21
pathe: 1.1.2
'@vitest/spy@2.1.9':
dependencies:
tinyspy: 3.0.2
'@vitest/utils@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
loupe: 3.2.1
tinyrainbow: 1.2.0
'@vue/compiler-core@3.4.38': '@vue/compiler-core@3.4.38':
dependencies: dependencies:
'@babel/parser': 7.25.6 '@babel/parser': 7.25.6
@@ -919,13 +1102,15 @@ snapshots:
acorn@8.18.0: acorn@8.18.0:
optional: true optional: true
assertion-error@2.0.1: {}
async-validator@4.2.5: {} async-validator@4.2.5: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
axios@1.7.7: axios@1.7.7(debug@4.4.3):
dependencies: dependencies:
follow-redirects: 1.15.6 follow-redirects: 1.15.6(debug@4.4.3)
form-data: 4.0.0 form-data: 4.0.0
proxy-from-env: 1.1.0 proxy-from-env: 1.1.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -934,6 +1119,18 @@ snapshots:
buffer-from@1.1.2: buffer-from@1.1.2:
optional: true optional: true
cac@6.7.14: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -961,6 +1158,12 @@ snapshots:
date-fns@4.4.0: {} date-fns@4.4.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
entities@4.5.0: {} entities@4.5.0: {}
@@ -970,6 +1173,8 @@ snapshots:
prr: 1.0.1 prr: 1.0.1
optional: true optional: true
es-module-lexer@1.7.0: {}
esbuild@0.21.5: esbuild@0.21.5:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5 '@esbuild/aix-ppc64': 0.21.5
@@ -998,9 +1203,17 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.5
evtd@0.2.4: {} evtd@0.2.4: {}
follow-redirects@1.15.6: {} expect-type@1.4.0: {}
follow-redirects@1.15.6(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
form-data@4.0.0: form-data@4.0.0:
dependencies: dependencies:
@@ -1046,9 +1259,15 @@ snapshots:
lodash@4.18.1: {} lodash@4.18.1: {}
loupe@3.2.1: {}
magic-string@0.30.11: magic-string@0.30.11:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@2.1.0: make-dir@2.1.0:
dependencies: dependencies:
@@ -1065,6 +1284,8 @@ snapshots:
mime@1.6.0: mime@1.6.0:
optional: true optional: true
ms@2.1.3: {}
naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)): naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)):
dependencies: dependencies:
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14) '@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
@@ -1098,6 +1319,10 @@ snapshots:
parse-node-version@1.0.1: parse-node-version@1.0.1:
optional: true optional: true
pathe@1.1.2: {}
pathval@2.0.1: {}
picocolors@1.1.0: {} picocolors@1.1.0: {}
pify@4.0.1: pify@4.0.1:
@@ -1147,6 +1372,8 @@ snapshots:
semver@5.7.2: semver@5.7.2:
optional: true optional: true
siginfo@2.0.0: {}
source-map-js@1.2.0: {} source-map-js@1.2.0: {}
source-map-support@0.5.21: source-map-support@0.5.21:
@@ -1158,6 +1385,10 @@ snapshots:
source-map@0.6.1: source-map@0.6.1:
optional: true optional: true
stackback@0.0.2: {}
std-env@3.10.0: {}
terser@5.31.6: terser@5.31.6:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.6 '@jridgewell/source-map': 0.3.6
@@ -1168,6 +1399,16 @@ snapshots:
tinode-sdk@0.25.4: {} tinode-sdk@0.25.4: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinypool@1.1.1: {}
tinyrainbow@1.2.0: {}
tinyspy@3.0.2: {}
to-fast-properties@2.0.0: {} to-fast-properties@2.0.0: {}
treemate@0.3.11: {} treemate@0.3.11: {}
@@ -1184,6 +1425,24 @@ snapshots:
evtd: 0.2.4 evtd: 0.2.4
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
vite-node@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6): vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
@@ -1195,6 +1454,41 @@ snapshots:
less: 4.2.0 less: 4.2.0
terser: 5.31.6 terser: 5.31.6
vitest@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 1.1.2
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vite-node: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.5.2
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vooks@0.2.12(vue@3.4.38(typescript@5.5.4)): vooks@0.2.12(vue@3.4.38(typescript@5.5.4)):
dependencies: dependencies:
evtd: 0.2.4 evtd: 0.2.4
@@ -1225,3 +1519,8 @@ snapshots:
vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4)) vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4))
vooks: 0.2.12(vue@3.4.38(typescript@5.5.4)) vooks: 0.2.12(vue@3.4.38(typescript@5.5.4))
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="悦选平台横幅">
<defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#1ba8ee"/><stop offset="1" stop-color="#3136dc"/></linearGradient></defs>
<rect width="1200" height="360" rx="28" fill="url(#g)"/>
<circle cx="1010" cy="70" r="180" fill="#fff" opacity=".10"/><circle cx="1120" cy="320" r="220" fill="#fff" opacity=".08"/>
<text x="90" y="160" fill="#fff" font-size="66" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
<text x="92" y="230" fill="#fff" font-size="34" font-family="PingFang SC, Microsoft YaHei, sans-serif" opacity=".9">精选商品 · 安心服务</text>
</svg>

After

Width:  |  Height:  |  Size: 727 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="悦选商品占位图">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#eaf7ff"/><stop offset="1" stop-color="#dfe3ff"/></linearGradient></defs>
<rect width="800" height="800" rx="48" fill="url(#g)"/>
<path d="M220 300h360l-28 300H248z" fill="#fff" stroke="#2878e8" stroke-width="22"/>
<path d="M310 315c0-118 180-118 180 0" fill="none" stroke="#2878e8" stroke-width="22" stroke-linecap="round"/>
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
</svg>

After

Width:  |  Height:  |  Size: 687 B

+1 -1
View File
@@ -13,7 +13,7 @@ const reasonMessages: Record<string, string> = {
RATE_LIMITED: '操作过于频繁,请稍后再试', RATE_LIMITED: '操作过于频繁,请稍后再试',
TASK_DISABLED: '领取功能暂未开放', TASK_DISABLED: '领取功能暂未开放',
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内', OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
INSUFFICIENT_BALANCE: '可用余额不足', INSUFFICIENT_BALANCE: '可用余额不足299元,暂时无法领取',
DAILY_LIMIT_REACHED: '今日领取次数已用完', DAILY_LIMIT_REACHED: '今日领取次数已用完',
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理', PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
NO_TASK_AVAILABLE: '当前暂无可领取任务', NO_TASK_AVAILABLE: '当前暂无可领取任务',
@@ -35,6 +35,7 @@
title: string; title: string;
content: string; content: string;
body_html?: string; body_html?: string;
source_type?: string;
} }
const props = defineProps<{ const props = defineProps<{
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
vi.mock('tinode-sdk', () => {
class QueryBuilder {
private kind = '';
private since?: number;
private limit?: number;
constructor(private topic?: { maxMsgSeq?: () => number }) {}
withData(_since?: number, _before?: number, limit?: number) {
this.kind = 'initial';
this.limit = limit;
return this;
}
withLaterData(limit?: number) {
this.kind = 'later';
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
this.limit = limit;
return this;
}
build() {
return { kind: this.kind, since: this.since, limit: this.limit };
}
}
class MockTinode {
onConnect?: () => Promise<void> | void;
onDisconnect?: () => void;
onAutoreconnectIteration?: (timeoutMs: number) => void;
_connection = { sendText: vi.fn() };
connect = vi.fn(async () => undefined);
loginToken = vi.fn(async () => ({ code: 200 }));
disconnect = vi.fn();
authorizeURL = vi.fn((value: string) => value);
topic = {
onData: undefined as (() => void) | undefined,
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
maxSeq: 0,
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
startMetaQuery: () => new QueryBuilder(this.topic),
subscribe: vi.fn(async () => {
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
return { code: 200, params: { what: 'data', count: 0 } };
}),
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
leave: vi.fn(),
messages: vi.fn(),
createMessage: vi.fn(),
publishDraft: vi.fn(),
};
constructor(public options: Record<string, unknown>) {
mocks.instances.push(this);
}
getTopic() {
return this.topic;
}
}
return {
Tinode: MockTinode,
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
};
});
import {
CustomerServiceChat,
resolveCustomerServiceEndpoints,
type CustomerServiceSession,
} from './im';
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
tenant_code: 'default',
api_base_url: '/v1/im',
file_url: '/v0/file',
websocket_url: '/v0/channels',
api_key: 'public-key',
token: 'token-1',
current_user_id: 'usr_member',
topic: 'p2p-topic',
...overrides,
});
describe('customer service IM endpoints', () => {
it('resolves API, file and websocket paths against the current page origin', () => {
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
fileURL: 'https://www.yuexuan2.shop/v0/file',
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
});
});
it('does not follow legacy absolute IM hosts from a cached session response', () => {
expect(
resolveCustomerServiceEndpoints(
session({
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
file_url: 'https://im-api.yuexuan.shop/v0/file',
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
}),
'https://www.yuexuan3.shop',
),
).toEqual({
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
fileURL: 'https://www.yuexuan3.shop/v0/file',
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
});
});
});
describe('customer service reconnect', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('reauthenticates and resubscribes with later data after reconnect', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan1.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.onDisconnect?.();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
expect(states.at(-1)).toBe('online');
});
it('does not restore a deliberately disconnected chat', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
chat.disconnect();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
});
it('reports offline without leaking a rejected reconnect callback', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan3.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
tinode.onDisconnect?.();
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
expect(states.at(-1)).toBe('offline');
});
it('uses rotated login tokens for reconnect and media upload', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
tinode.loginToken
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
await chat.connect('token-1');
await tinode.onConnect?.();
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
fetchMock.mockRestore();
});
it('paginates all messages accumulated while disconnected', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.topic.subscribe.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 50;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
});
tinode.topic.getMeta
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 100;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
})
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 120;
tinode.topic.onAllMessagesReceived?.(20);
}, 10);
return { code: 200, params: { what: 'data', count: 20 } };
});
await tinode.onConnect?.();
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
{ kind: 'later', limit: 50, since: 51 },
{ kind: 'later', limit: 50, since: 101 },
]);
});
});
describe('customer service media upload', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('keeps credentials in headers and out of the upload URL', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
expect(String(url)).not.toContain('secret=');
expect(init?.headers).toEqual({
'X-MeetingIM-APIKey': 'public-key',
Authorization: 'Token token-1',
});
fetchMock.mockRestore();
});
it('rewrites historical absolute IM file references to the current frontend origin', () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
const tinode = mocks.instances[0];
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
);
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
'https://cdn.example.com/public/image.png',
);
});
it('renders inline Drafty images sent by the IM web client', () => {
const onMessages = vi.fn();
new CustomerServiceChat(session(), onMessages, { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
tinode.topic.messages.mockImplementation((callback: (message: unknown) => void) => {
callback({
seq: 4,
from: 'usr_support',
ts: '2026-10-19T10:02:32.090Z',
content: {
ent: [{
tp: 'IM',
data: {
val: 'aGVsbG8=',
mime: 'image/png',
name: 'proof.png',
width: 70,
height: 70,
},
}],
fmt: [{ len: 1 }],
txt: ' ',
},
});
});
tinode.topic.onData?.();
expect(onMessages).toHaveBeenCalledWith([
expect.objectContaining({
seq: 4,
type: 'image',
media_url: 'data:image/png;base64,aGVsbG8=',
file_name: 'proof.png',
}),
]);
});
});
+170 -22
View File
@@ -25,20 +25,59 @@ export interface ChatMessage {
file_name?: string; file_name?: string;
} }
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
interface CustomerServiceChatOptions {
origin?: string;
onConnectionState?: (state: CustomerServiceConnectionState) => void;
}
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
const pageOrigin = new URL(origin);
const sameOriginURL = (value: string, fallback: string) => {
const configured = new URL(value || fallback, pageOrigin);
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
return resolved.toString().replace(/\/$/, '');
};
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
return {
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
fileURL: sameOriginURL(session.file_url, '/v0/file'),
websocketURL: websocket.toString().replace(/\/$/, ''),
};
}
export class CustomerServiceChat { export class CustomerServiceChat {
private static readonly reconnectPageSize = 50;
private static readonly maxReconnectPages = 100;
private tinode: any; private tinode: any;
private topic: any; private topic: any;
private currentUserID: string; private currentUserID: string;
private onMessages: (messages: ChatMessage[]) => void; private onMessages: (messages: ChatMessage[]) => void;
private session: CustomerServiceSession; private session: CustomerServiceSession;
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
private token = '';
private sessionActive = false;
private intentionalDisconnect = false;
private recovery: Promise<void> | null = null;
private onConnectionState: (state: CustomerServiceConnectionState) => void;
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) { constructor(
const endpoint = new URL(session.websocket_url); session: CustomerServiceSession,
onMessages: (messages: ChatMessage[]) => void,
options: CustomerServiceChatOptions = {},
) {
const origin = options.origin || window.location.origin;
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
const endpoint = new URL(this.endpoints.websocketURL);
this.session = session; this.session = session;
this.token = session.token;
this.currentUserID = session.current_user_id; this.currentUserID = session.current_user_id;
this.onMessages = onMessages; this.onMessages = onMessages;
this.onConnectionState = options.onConnectionState || (() => undefined);
this.tinode = new Tinode({ this.tinode = new Tinode({
appName: 'Olons Web', appName: '悦选',
host: endpoint.host, host: endpoint.host,
apiKey: session.api_key, apiKey: session.api_key,
transport: 'ws', transport: 'ws',
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
this.installTenantPatch(session.tenant_code); this.installTenantPatch(session.tenant_code);
this.topic = this.tinode.getTopic(session.topic); this.topic = this.tinode.getTopic(session.topic);
this.topic.onData = () => this.refreshMessages(); this.topic.onData = () => this.refreshMessages();
this.tinode.onDisconnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState('reconnecting');
}
};
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
}
};
this.tinode.onConnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
return this.restoreAfterReconnect();
}
};
} }
async connect(token: string) { async connect(token: string) {
this.intentionalDisconnect = false;
this.token = token;
this.onConnectionState('connecting');
await this.tinode.connect(); await this.tinode.connect();
const login = await this.tinode.loginToken(token); const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话登录失败'); if (login?.code >= 300) throw new Error('客服会话登录失败');
this.acceptRotatedToken(login);
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build(); const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
const subscribed = await this.topic.subscribe(query); await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败'); this.sessionActive = true;
this.onConnectionState('online');
this.refreshMessages(); this.refreshMessages();
} }
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
} }
disconnect() { disconnect() {
this.intentionalDisconnect = true;
this.sessionActive = false;
this.onConnectionState('offline');
try { try {
this.topic?.leave?.(false); this.topic?.leave?.(false);
} finally { } finally {
@@ -108,6 +170,85 @@ export class CustomerServiceChat {
} }
} }
private restoreAfterReconnect() {
if (this.recovery) return this.recovery;
this.recovery = (async () => {
this.onConnectionState('reconnecting');
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
this.acceptRotatedToken(login);
await this.restoreSubscriptionAndMessages();
this.refreshMessages();
this.onConnectionState('online');
})()
.catch(() => {
this.onConnectionState('offline');
})
.finally(() => {
this.recovery = null;
});
return this.recovery;
}
private acceptRotatedToken(login: any) {
const rotated = String(login?.params?.token || '');
if (!rotated) return;
this.token = rotated;
this.session.token = rotated;
}
private async restoreSubscriptionAndMessages() {
const pageSize = CustomerServiceChat.reconnectPageSize;
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
for (let page = 1; count >= pageSize; page += 1) {
if (page >= CustomerServiceChat.maxReconnectPages) {
throw new Error('客服消息积压过多,请重新进入客服会话');
}
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
if (count > 0 && currentMax <= previousMax) {
throw new Error('客服历史消息补拉未推进');
}
}
}
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
let observedCount: number | undefined;
let resolveObserved: ((count: number) => void) | undefined;
const observed = new Promise<number>((resolve) => {
resolveObserved = resolve;
});
const previousHandler = this.topic.onAllMessagesReceived;
this.topic.onAllMessagesReceived = (count: number) => {
previousHandler?.(count);
observedCount = Number(count || 0);
resolveObserved?.(observedCount);
};
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const response = await request();
if (response?.code >= 300) throw new Error(errorMessage);
if (observedCount !== undefined) return observedCount;
return await Promise.race([
observed,
new Promise<number>((_, reject) => {
// A Tinode request promise resolves before the SDK finishes routing
// its data packets. Only this completion callback guarantees that
// maxMsgSeq and the local message cache belong to the finished page.
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
this.topic.onAllMessagesReceived = previousHandler;
}
}
private installTenantPatch(tenantCode: string) { private installTenantPatch(tenantCode: string) {
const connection = this.tinode._connection; const connection = this.tinode._connection;
if (!connection || connection.__olonsTenantPatched) return; if (!connection || connection.__olonsTenantPatched) return;
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
} }
private async upload(file: File, onProgress?: (progress: number) => void) { private async upload(file: File, onProgress?: (progress: number) => void) {
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url); const uploadURL = new URL(this.endpoints.fileURL);
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`; uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
uploadURL.searchParams.set('apikey', this.session.api_key);
uploadURL.searchParams.set('auth', 'token');
uploadURL.searchParams.set('secret', this.session.token);
const form = new FormData(); const form = new FormData();
form.set('id', clientMID()); form.set('id', clientMID());
form.set('file', file, file.name); form.set('file', file, file.name);
onProgress?.(0.1); onProgress?.(0.1);
let response: Response; let response: Response;
try { try {
// Do not attach XMLHttpRequest upload progress listeners here. For a // Keep uploads on fetch so API and token credentials stay in headers and
// cross-origin IM endpoint they trigger a CORS preflight which the file // never leak into the URL, browser history, or proxy access logs.
// service does not expose, while multipart/form-data itself is accepted. response = await fetch(uploadURL.toString(), {
response = await fetch(uploadURL.toString(), { method: 'POST', body: form }); method: 'POST',
headers: {
'X-MeetingIM-APIKey': this.session.api_key,
Authorization: `Token ${this.token}`,
},
body: form,
});
} catch { } catch {
throw new Error('媒体上传失败,请检查网络后重试'); throw new Error('媒体上传失败,请检查网络后重试');
} }
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
private mediaURL(ref?: string) { private mediaURL(ref?: string) {
if (!ref) return ''; if (!ref) return '';
const apiBase = new URL(this.session.api_base_url); const apiBase = new URL(this.endpoints.apiBaseURL);
const resolved = new URL(ref, apiBase); const resolved = new URL(ref, apiBase);
// The SDK signs relative file URLs. Also tolerate historical absolute IM // Historical messages may contain the old absolute IM host. Route only
// references by converting same-origin URLs back to a signable path. // known IM file paths through the current frontend origin; leave unrelated
const signable = resolved.origin === apiBase.origin // external media untouched.
? `${resolved.pathname}${resolved.search}` if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
: ref; const signable = `${resolved.pathname}${resolved.search}`;
const authorized = this.tinode.authorizeURL(signable); const authorized = this.tinode.authorizeURL(signable);
return new URL(authorized, this.session.api_base_url).toString(); return new URL(authorized, this.endpoints.apiBaseURL).toString();
} }
} }
@@ -205,10 +349,14 @@ function messageContent(content: unknown, mediaURL: (ref?: string) => string) {
const media = drafty.ent?.find((entity) => entity?.tp === 'IM' || entity?.tp === 'VD'); const media = drafty.ent?.find((entity) => entity?.tp === 'IM' || entity?.tp === 'VD');
if (media?.data) { if (media?.data) {
const type = media.tp === 'VD' ? 'video' : 'image'; const type = media.tp === 'VD' ? 'video' : 'image';
const inline = typeof media.data.val === 'string' ? media.data.val : '';
const mime = typeof media.data.mime === 'string' && media.data.mime.startsWith(`${type}/`)
? media.data.mime
: type === 'video' ? 'video/webm' : 'image/jpeg';
return { return {
text: '', text: '',
type, type,
media_url: mediaURL(String(media.data.ref || '')), media_url: inline ? `data:${mime};base64,${inline}` : mediaURL(String(media.data.ref || '')),
preview_url: mediaURL(String(media.data.preref || '')), preview_url: mediaURL(String(media.data.preref || '')),
file_name: String(media.data.name || ''), file_name: String(media.data.name || ''),
} as const; } as const;
+125 -139
View File
@@ -524,19 +524,57 @@ body {
font-weight: 500; font-weight: 500;
} }
.m-notice { .m-notice {
width: 100%;
margin-top: 12px; margin-top: 12px;
padding: 12px 16px; padding: 12px 16px;
border: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
color: #6c5a45; color: #6c5a45;
font: inherit;
text-align: left;
cursor: pointer;
} }
.m-notice p { .m-notice p {
flex: 1;
min-width: 0;
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.m-notice-tag {
flex: none;
padding: 2px 6px;
border-radius: 4px;
color: #8d5b17;
background: #fff2d8;
font-size: 12px;
line-height: 18px;
}
.m-notice-tag.is-activity {
color: #24704b;
background: #e9f7ef;
}
.m-notice-copy {
animation: m-notice-enter 0.32s ease-out;
}
.m-notice-arrow {
flex: none;
color: #b4a590;
font-size: 17px;
}
@keyframes m-notice-enter {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.m-section { .m-section {
margin-top: 12px; margin-top: 12px;
padding: 17px 16px; padding: 17px 16px;
@@ -1455,6 +1493,48 @@ body {
padding: 9px 16px; padding: 9px 16px;
font-size: 13px; font-size: 13px;
} }
.m-chat-connection {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 36px;
padding: 8px 14px;
color: #8a5a00;
background: #fff3cd;
font-size: 13px;
}
.m-chat-connection.is-offline {
color: #9f2d2d;
background: #fde7e7;
}
.m-chat-connection button,
.m-chat-media-error button {
border: 0;
padding: 0;
color: currentColor;
background: transparent;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.m-chat-media-error {
display: flex;
min-width: 160px;
min-height: 96px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #8b3a3a;
background: #f8eaea;
border-radius: 12px;
font-size: 13px;
}
.m-chat-empty { .m-chat-empty {
min-height: 530px; min-height: 530px;
padding: 100px 30px 20px; padding: 100px 30px 20px;
@@ -3543,105 +3623,40 @@ body {
/* Olons 2 manual task mall */ /* Olons 2 manual task mall */
.m-task-mall { .m-task-mall {
min-height: 100dvh; min-height: 100dvh;
padding: calc(18px + env(safe-area-inset-top)) 20px 32px; padding: 0 12px 32px;
background-color: #fffaf4; background-color: #fffaf4;
background-size: 100% auto; background-size: 100% auto;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.m-mall-search { .m-task-mall > .m-page-header {
display: grid; margin-inline: -2px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.m-mall-search h1 {
margin: 0;
font-size: 28px;
}
.m-mall-search label {
height: 48px;
min-width: 0;
padding: 0 14px;
border-radius: 25px;
background: #fff;
color: #aaa;
display: flex;
align-items: center;
gap: 8px;
}
.m-mall-search label .n-icon {
font-size: 24px;
}
.m-mall-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
}
.m-mall-search > button,
.m-mall-claim {
border: 0;
color: #fff;
background: #ff8b16;
}
.m-mall-search > button {
min-width: 72px;
height: 48px;
border-radius: 25px;
font-size: 17px;
} }
.m-mall-banner { .m-mall-banner {
display: block; display: block;
width: 100%; width: 100%;
margin-top: 18px; margin: 0 0 12px;
border-radius: 8px; border-radius: 8px;
} }
.m-mall-tabs {
min-height: 62px;
display: flex;
align-items: center;
gap: 24px;
}
.m-mall-tabs button {
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
font-size: 19px;
}
.m-mall-tabs button.active {
color: #ff6d50;
font-weight: 600;
}
.m-mall-tabs > .n-icon {
margin-left: auto;
font-size: 24px;
}
.m-mall-list { .m-mall-list {
display: grid; display: grid;
gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 6px;
} }
.m-mall-task { .m-mall-task {
min-height: 128px; min-width: 0;
padding: 12px; padding: 0 0 11px;
border-radius: 8px;
background: #fff;
display: grid;
grid-template-columns: 104px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.m-mall-cover {
width: 104px;
aspect-ratio: 1;
padding: 0;
border: 0; border: 0;
border-radius: 8px; border-radius: 8px;
background: #fff;
color: var(--m-ink);
overflow: hidden; overflow: hidden;
background: #f0f0f0; text-align: left;
position: relative; }
.m-mall-cover {
display: grid; display: grid;
width: 100%;
aspect-ratio: 1;
background: #f0f0f0;
place-items: center; place-items: center;
} }
.m-mall-cover img { .m-mall-cover img {
@@ -3653,45 +3668,22 @@ body {
color: #bbb; color: #bbb;
font-size: 38px; font-size: 38px;
} }
.m-mall-cover span { .m-mall-task > strong {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 4px;
background: rgba(109, 48, 11, 0.88);
color: #fff;
font-size: 12px;
}
.m-mall-task > div {
min-width: 0;
}
.m-mall-task h2 {
display: -webkit-box; display: -webkit-box;
margin: 0 0 7px; min-height: 2.5em;
margin: 8px 10px 3px;
overflow: hidden; overflow: hidden;
font-size: 17px; font-size: clamp(15px, 4.3vw, 19px);
line-height: 1.35; font-weight: 600;
line-height: 1.22;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 3;
} }
.m-mall-task strong { .m-mall-task > small {
display: block; display: block;
color: #ff684d; margin: 0 10px;
font-size: 21px; color: #555;
} font-size: 13px;
.m-mall-task strong small {
font-size: 14px;
}
.m-mall-task p {
margin: 5px 0 0;
color: #b5b5b5;
}
.m-mall-claim {
min-width: 66px;
height: 38px;
border-radius: 20px;
font-size: 16px;
} }
/* Olons 2 leaderboard */ /* Olons 2 leaderboard */
@@ -3760,10 +3752,18 @@ body {
.m-rank-winner > strong, .m-rank-winner > strong,
.m-rank-winner > em { .m-rank-winner > em {
max-width: 100%; max-width: 100%;
font-style: normal;
}
.m-rank-winner > strong {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-style: normal; }
.m-rank-winner > em {
overflow: visible;
font-size: clamp(11px, 3vw, 14px);
line-height: 1.2;
overflow-wrap: anywhere;
} }
.m-rank-winner > small { .m-rank-winner > small {
margin-top: 4px; margin-top: 4px;
@@ -3781,7 +3781,7 @@ body {
.m-rank-table header, .m-rank-table header,
.m-rank-row { .m-rank-row {
display: grid; display: grid;
grid-template-columns: 48px minmax(0, 1.5fr) minmax(82px, 1fr) 70px; grid-template-columns: 48px minmax(0, 1fr) max-content 70px;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
} }
@@ -3823,6 +3823,7 @@ body {
font-style: normal; font-style: normal;
} }
.m-rank-row strong { .m-rank-row strong {
white-space: nowrap;
font-size: 17px; font-size: 17px;
font-weight: 500; font-weight: 500;
} }
@@ -3849,7 +3850,7 @@ body {
.m-home-rank-list a { .m-home-rank-list a {
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: 30px 34px minmax(0, 1fr) 72px 82px; grid-template-columns: 30px 34px minmax(0, 1fr) max-content 82px;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
color: var(--m-ink); color: var(--m-ink);
@@ -3895,14 +3896,16 @@ body {
.m-home-rank-list strong, .m-home-rank-list strong,
.m-home-rank-list em { .m-home-rank-list em {
min-width: 0; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.m-home-rank-list strong { .m-home-rank-list strong {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500; font-weight: 500;
} }
.m-home-rank-list em { .m-home-rank-list em {
overflow: visible;
font-size: clamp(12px, 3.2vw, 15px);
font-style: normal; font-style: normal;
text-align: right; text-align: right;
font-weight: 600; font-weight: 600;
@@ -4017,36 +4020,19 @@ body {
.m-leaderboard { .m-leaderboard {
padding-inline: 14px; padding-inline: 14px;
} }
.m-mall-task {
grid-template-columns: 92px minmax(0, 1fr) auto;
gap: 9px;
padding: 10px;
}
.m-mall-cover {
width: 92px;
}
.m-mall-search {
gap: 8px;
}
.m-mall-search h1 {
font-size: 23px;
}
.m-mall-search > button {
min-width: 60px;
}
.m-claim-result { .m-claim-result {
padding-inline: 20px; padding-inline: 20px;
} }
.m-rank-table header, .m-rank-table header,
.m-rank-row { .m-rank-row {
grid-template-columns: 38px minmax(0, 1.4fr) 72px 58px; grid-template-columns: 38px minmax(0, 1fr) max-content 58px;
gap: 5px; gap: 5px;
} }
.m-rank-table { .m-rank-table {
padding-inline: 8px; padding-inline: 8px;
} }
.m-home-rank-list a { .m-home-rank-list a {
grid-template-columns: 26px 32px minmax(0, 1fr) 54px 72px; grid-template-columns: 26px 32px minmax(0, 1fr) max-content 68px;
gap: 5px; gap: 5px;
} }
.m-home-rank-list small { .m-home-rank-list small {
+19 -21
View File
@@ -5,7 +5,7 @@
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" /> <img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button <button
type="button" type="button"
:disabled="claiming || !eligibleHalls.length" :disabled="claiming || !halls.length"
aria-label="自动领取任务" aria-label="自动领取任务"
@click="claim" @click="claim"
> >
@@ -22,21 +22,22 @@
</div> </div>
</section> </section>
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning"> <div v-if="balanceInsufficient" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" /> <n-icon :component="AlertCircleOutline" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span> <span>可用余额不足299元</span>
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
</div> </div>
<section class="m-claim2-rules"> <section class="m-claim2-rules">
<h2><span></span>活动规则<span></span></h2> <h2><span></span>活动规则<span></span></h2>
<ol> <ol>
<li>平台运营时间每天 {{ businessHours }}。</li> <li>平台运营时间每天上午9:00-晚上21:00点!</li>
<li>每一次储入请先咨询客服获取商家卡号,如有疑问请联系在线客服。</li> <li>每一次储入必须咨询客户获取商家卡号!如有不理解可联系在线客服!</li>
<li>领取后系统将自动分配任务,请按订单要求完成任务内容。</li> <li>领取后系统将自动分配任务,请按订单要求完成任务内容!</li>
<li>领取时间根据会员等级不同,系统随机匹配订单。</li> <li>领取订单的时间是根据会员等级的不同!系统随机匹配订单!</li>
<li>账户可用余额需达到 {{ money(eligibility?.minimum_amount) }} 才可领取。</li> <li>会员账户必须要拥有299启动资金才可进行领取!</li>
<li>订单需要在规定时间内完成,请勿超时提交。</li> <li>订单需要在规定时间内完成!请勿超时完成商品!</li>
<li>平台硬性条件提现必须信用分值满足100分,请广大会员珍惜账户信用分值!信用分下降必须联系客服处理恢复100分才可正常提现,信用分提升金额具体根据会员等级规定,详细咨询平台客服。</li>
<li>非本人充值资金需要提供支付人身份证信息验证充值资金,避免出现非必要的资金纠纷。</li>
</ol> </ol>
</section> </section>
</div> </div>
@@ -132,11 +133,12 @@
const eligibleHalls = computed(() => const eligibleHalls = computed(() =>
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible) halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
); );
const businessHours = computed(() => { const balanceInsufficient = computed(() =>
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5); Object.values(eligibilityByHall.value).some((item: any) =>
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5); Number(item?.minimum_amount || 0) > 0 &&
return `${start}-${end}`; Number(item?.available_amount || 0) < Number(item.minimum_amount)
}); )
);
const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual'); const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual');
const matchingHint = computed(() => { const matchingHint = computed(() => {
if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...'; if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...';
@@ -202,12 +204,12 @@
if (claiming.value || !halls.value.length) return; if (claiming.value || !halls.value.length) return;
claiming.value = true; claiming.value = true;
pollFinished.value = false; pollFinished.value = false;
order.value = undefined;
try { try {
await loadEligibilities(); await loadEligibilities();
const candidates = eligibleHalls.value; const candidates = eligibleHalls.value;
if (!candidates.length) { if (!candidates.length) {
window.$message.warning(eligibility.value?.message || '暂不满足领取条件'); throw new Error(eligibility.value?.message || '暂不满足领取条件');
return;
} }
openMatching(); openMatching();
let stockError: unknown; let stockError: unknown;
@@ -348,10 +350,6 @@
closeResult(); closeResult();
router.push('/app/orders'); router.push('/app/orders');
} }
function openPendingOrder() {
const orderID = Number(eligibility.value?.pending_order_id || 0);
router.push(orderID > 0 ? `/app/orders/${orderID}` : '/app/orders');
}
function retryClaim() { function retryClaim() {
closeResult(); closeResult();
void claim(); void claim();
+2 -13
View File
@@ -52,15 +52,10 @@
<AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts"> <AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
<form class="m-plain-form m-bank-form" @submit.prevent="saveBank"> <form class="m-plain-form m-bank-form" @submit.prevent="saveBank">
<p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名、身份证号和银行卡号后保存</p> <p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名和银行卡号后保存</p>
<label <label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名" ><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label> /></label>
<label
><span>身份证号</span
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
/></label>
<div class="m-bank-form__divider"></div>
<label <label
><span>银行卡号</span ><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号" ><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
@@ -266,7 +261,6 @@
id: number; id: number;
bank_name: string; bank_name: string;
holder_name: string; holder_name: string;
identity_no: string;
account_no: string; account_no: string;
digital_rmb_code: string; digital_rmb_code: string;
wechat_qr_url: string; wechat_qr_url: string;
@@ -282,7 +276,6 @@
const bank = reactive({ const bank = reactive({
bank_name: '', bank_name: '',
holder_name: '', holder_name: '',
identity_no: '',
account_no: '', account_no: '',
digital_rmb_code: '', digital_rmb_code: '',
wechat_qr_attachment_id: 0, wechat_qr_attachment_id: 0,
@@ -344,7 +337,7 @@
bankLoadError.value = ''; bankLoadError.value = '';
error.value = ''; error.value = '';
existingAccount.value = null; existingAccount.value = null;
Object.assign(bank, { bank_name: '', holder_name: '', identity_no: '', account_no: '', digital_rmb_code: '', Object.assign(bank, { bank_name: '', holder_name: '', account_no: '', digital_rmb_code: '',
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 }); wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
qr.wechat.url = ''; qr.wechat.url = '';
qr.alipay.url = ''; qr.alipay.url = '';
@@ -359,7 +352,6 @@
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id; bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
if (!account.masked) { if (!account.masked) {
bank.holder_name = account.holder_name; bank.holder_name = account.holder_name;
bank.identity_no = account.identity_no;
bank.account_no = account.account_no; bank.account_no = account.account_no;
bank.digital_rmb_code = account.digital_rmb_code; bank.digital_rmb_code = account.digital_rmb_code;
} }
@@ -371,14 +363,11 @@
} }
async function saveBank() { async function saveBank() {
error.value = ''; error.value = '';
const identityNo = bank.identity_no.replace(/[ -]/g, '');
const accountNo = bank.account_no.replace(/[ -]/g, ''); const accountNo = bank.account_no.replace(/[ -]/g, '');
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, ''); const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
const validationError = const validationError =
bank.holder_name.length < 2 bank.holder_name.length < 2
? '请输入至少 2 个字符的姓名' ? '请输入至少 2 个字符的姓名'
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
? '请输入正确的 15 位或 18 位身份证号'
: !/^\d{8,32}$/.test(accountNo) : !/^\d{8,32}$/.test(accountNo)
? '请输入 8 至 32 位银行卡号' ? '请输入 8 至 32 位银行卡号'
: bank.bank_name.length < 2 : bank.bank_name.length < 2
+73 -19
View File
@@ -1,8 +1,8 @@
<template> <template>
<section class="m-home"> <section class="m-home">
<AnnouncementPopup <AnnouncementPopup
:show="Boolean(popupNotice)" :show="popupVisible"
:notice="popupNotice" :notice="loginPopupNotice"
@read="markPopupRead" @read="markPopupRead"
/> />
<div class="m-home-hero" :style="bannerStyle"> <div class="m-home-hero" :style="bannerStyle">
@@ -53,10 +53,22 @@
</button> </button>
</div> </div>
<div v-if="home?.notices?.length" class="m-notice m-card"> <button
v-if="displayNotice"
class="m-notice m-card"
type="button"
:aria-label="`查看公告:${displayNotice.title || '最新公告'}`"
@click="openNotice"
>
<n-icon :component="MegaphoneOutline" /> <n-icon :component="MegaphoneOutline" />
<p>{{ home.notices[0].title || home.notices[0].content }}</p> <span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
</div> {{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
</span>
<p :key="displayNotice.id" class="m-notice-copy">
{{ displayNotice.title || displayNotice.content || '查看最新通知' }}
</p>
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
</button>
<section class="m-section m-card m-home-ranking"> <section class="m-section m-card m-home-ranking">
<div class="m-section-title" <div class="m-section-title"
@@ -74,7 +86,7 @@
<i v-else>{{ entry.username.slice(0, 1) }}</i> <i v-else>{{ entry.username.slice(0, 1) }}</i>
</span> </span>
<strong>{{ entry.username }}</strong> <strong>{{ entry.username }}</strong>
<em>{{ formatCount(entry.completed_count) }}</em> <em>{{ formatPoints(entry.claimed_points) }}积分</em>
<small>{{ entry.status || '领取成功' }}</small> <small>{{ entry.status || '领取成功' }}</small>
</router-link> </router-link>
</div> </div>
@@ -101,7 +113,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5'; import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import AnnouncementPopup, { import AnnouncementPopup, {
type AnnouncementNotice, type AnnouncementNotice,
@@ -121,7 +133,7 @@
wallet: { available_amount: number; frozen_amount: number; currency: string }; wallet: { available_amount: number; frozen_amount: number; currency: string };
features: Record<string, boolean>; features: Record<string, boolean>;
banners: BannerData[]; banners: BannerData[];
notices: Array<{ title?: string; content?: string }>; notices: NoticeData[];
task_summary: { task_summary: {
today_count: number; today_count: number;
today_commission_amount: number; today_commission_amount: number;
@@ -135,11 +147,17 @@
target_type: 'none' | 'internal_route' | 'external_url'; target_type: 'none' | 'internal_route' | 'external_url';
target_value?: string; target_value?: string;
} }
interface NoticeData {
id: number;
title?: string;
content?: string;
source_type?: string;
}
interface LeaderboardEntry { interface LeaderboardEntry {
rank: number; rank: number;
username: string; username: string;
avatar_url: string; avatar_url: string;
completed_count: number; claimed_points: number;
status: string; status: string;
} }
const router = useRouter(); const router = useRouter();
@@ -147,11 +165,20 @@
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]); const leaderboard = ref<LeaderboardEntry[]>([]);
const popupNotice = ref<AnnouncementNotice | null>(null); const loginPopupNotice = ref<AnnouncementNotice | null>(null);
const popupVisible = ref(false);
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
const activeBannerIndex = ref(0); const activeBannerIndex = ref(0);
const activeNoticeIndex = ref(0);
let bannerTimer: number | undefined; let bannerTimer: number | undefined;
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); let noticeTimer: number | undefined;
const formatPoints = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]); const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
const displayNotice = computed(() => homeAnnouncement.value || currentNotice.value);
const bannerStyle = computed(() => { const bannerStyle = computed(() => {
const url = safeImage(currentBanner.value?.image_url); const url = safeImage(currentBanner.value?.image_url);
return { return {
@@ -180,7 +207,7 @@
enabled: home.value?.features?.withdrawal_enabled === true, enabled: home.value?.features?.withdrawal_enabled === true,
}, },
{ {
label: '商城', label: '手工',
path: '/app/mall', path: '/app/mall',
icon: mobileAssets.shortcutMall, icon: mobileAssets.shortcutMall,
enabled: home.value?.features?.manual_mall_enabled !== false, enabled: home.value?.features?.manual_mall_enabled !== false,
@@ -231,13 +258,23 @@
}, 4500); }, 4500);
} }
} }
function restartNoticeTimer() {
if (noticeTimer) window.clearInterval(noticeTimer);
noticeTimer = undefined;
const count = home.value?.notices?.length || 0;
if (count > 1) {
noticeTimer = window.setInterval(() => {
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
}, 4000);
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const noticeRequest = shouldShowLoginNotice() const noticeRequest = request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null);
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
: Promise.resolve(null);
const [homeData, rankData, noticeData] = await Promise.all([ const [homeData, rankData, noticeData] = await Promise.all([
request<HomeData>({ url: '/v1/home' }), request<HomeData>({ url: '/v1/home' }),
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []), request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
@@ -245,7 +282,9 @@
]); ]);
home.value = homeData; home.value = homeData;
leaderboard.value = rankData || []; leaderboard.value = rankData || [];
popupNotice.value = noticeData; homeAnnouncement.value = noticeData;
loginPopupNotice.value = shouldShowLoginNotice() ? noticeData : null;
popupVisible.value = Boolean(loginPopupNotice.value);
} catch (err) { } catch (err) {
error.value = errorText(err); error.value = errorText(err);
} finally { } finally {
@@ -254,20 +293,35 @@
} }
function markPopupRead() { function markPopupRead() {
if (!popupNotice.value) return; if (!loginPopupNotice.value) return;
const noticeId = popupNotice.value.id; const noticeId = loginPopupNotice.value.id;
popupNotice.value = null; popupVisible.value = false;
loginPopupNotice.value = null;
completeLoginNotice(); completeLoginNotice();
if (noticeId > 0) { if (noticeId > 0) {
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined); request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
} }
} }
function openNotice() {
if (homeAnnouncement.value) {
loginPopupNotice.value = homeAnnouncement.value;
popupVisible.value = true;
return;
}
void router.push('/app/content/notices');
}
watch(() => home.value?.banners?.length, () => { watch(() => home.value?.banners?.length, () => {
activeBannerIndex.value = 0; activeBannerIndex.value = 0;
restartBannerTimer(); restartBannerTimer();
}); });
watch(() => home.value?.notices?.length, () => {
activeNoticeIndex.value = 0;
restartNoticeTimer();
});
onMounted(load); onMounted(load);
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (bannerTimer) window.clearInterval(bannerTimer); if (bannerTimer) window.clearInterval(bannerTimer);
if (noticeTimer) window.clearInterval(noticeTimer);
}); });
</script> </script>
+8 -5
View File
@@ -22,13 +22,13 @@
</div> </div>
<b>{{ entry.rank }}</b> <b>{{ entry.rank }}</b>
<strong>{{ entry.username }}</strong> <strong>{{ entry.username }}</strong>
<em>{{ number(entry.completed_count) }}</em> <em>{{ points(entry.claimed_points) }} 积分</em>
<small>{{ entry.status }}</small> <small>{{ entry.status }}</small>
</article> </article>
</div> </div>
<section v-if="remaining.length" class="m-rank-table"> <section v-if="remaining.length" class="m-rank-table">
<header><span>排名</span><span>用户昵称</span><span>领取数量</span><span>状态</span></header> <header><span>排名</span><span>用户昵称</span><span>领取积分</span><span>状态</span></header>
<div v-for="entry in remaining" :key="entry.rank" class="m-rank-row"> <div v-for="entry in remaining" :key="entry.rank" class="m-rank-row">
<b>{{ entry.rank }}</b> <b>{{ entry.rank }}</b>
<div class="m-rank-user"> <div class="m-rank-user">
@@ -38,7 +38,7 @@
</span> </span>
<span>{{ entry.username }}</span> <span>{{ entry.username }}</span>
</div> </div>
<strong>{{ number(entry.completed_count) }}</strong> <strong>{{ points(entry.claimed_points) }}</strong>
<em>{{ entry.status.replace('成功', '') }}</em> <em>{{ entry.status.replace('成功', '') }}</em>
</div> </div>
</section> </section>
@@ -57,7 +57,7 @@
rank: number; rank: number;
username: string; username: string;
avatar_url: string; avatar_url: string;
completed_count: number; claimed_points: number;
status: string; status: string;
} }
@@ -66,7 +66,10 @@
const error = ref(''); const error = ref('');
const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean)); const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean));
const remaining = computed(() => entries.value.slice(3)); const remaining = computed(() => entries.value.slice(3));
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); const points = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const podiumBackground = (rank: number) => const podiumBackground = (rank: number) =>
rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird; rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird;
+12 -46
View File
@@ -1,40 +1,25 @@
<template> <template>
<section v-if="!isDetail" class="m-task-mall" :style="{ backgroundImage: `url(${mobileAssets.mallBackground})` }"> <section v-if="!isDetail" class="m-task-mall" :style="{ backgroundImage: `url(${mobileAssets.mallBackground})` }">
<header class="m-mall-search"> <PageHeader title="手工商城" />
<h1>商城</h1>
<label><n-icon :component="SearchOutline" /><input v-model.trim="keyword" placeholder="搜索商品" /></label>
<button type="button" @click="applySearch">搜索</button>
</header>
<img class="m-mall-banner" :src="mobileAssets.mallBanner" alt="精选任务,轻松赚收益" /> <img class="m-mall-banner" :src="mobileAssets.mallBanner" alt="精选任务,轻松赚收益" />
<div class="m-mall-tabs">
<button :class="{ active: tab === 'recommended' }" @click="tab = 'recommended'">推荐任务</button>
<button :class="{ active: tab === 'limited' }" @click="tab = 'limited'">限时任务</button>
<n-icon :component="ChevronForwardOutline" />
</div>
<AsyncState <AsyncState
:loading="loading" :loading="loading"
:error="error" :error="error"
:empty="!visibleProducts.length" :empty="!products.length"
:empty-text="tab === 'limited' ? '暂无限时任务' : '暂无推荐任务'" empty-text="暂无手工商品"
@retry="loadList" @retry="loadList"
> >
<div class="m-mall-list"> <div class="m-mall-list">
<article v-for="item in visibleProducts" :key="item.id" class="m-mall-task"> <button v-for="item in products" :key="item.id" class="m-mall-task" type="button" @click="openSupport">
<button class="m-mall-cover" @click="openProduct(item.id)"> <span class="m-mall-cover">
<img v-if="safeImage(item.cover_url)" :src="safeImage(item.cover_url)" :alt="item.name" /> <img v-if="safeImage(item.cover_url)" :src="safeImage(item.cover_url)" :alt="item.name" />
<n-icon v-else :component="ImageOutline" /> <n-icon v-else :component="ImageOutline" />
<span v-if="item.is_limited">限时展示</span> </span>
</button> <strong>{{ item.name }}</strong>
<div> <small>已领取:{{ item.claimed_count || 0 }}</small>
<h2>{{ item.name }}</h2> </button>
<strong>{{ money(item.price_amount, item.currency) }}<small>/件</small></strong>
<p>已领取{{ item.claimed_count || 0 }}</p>
</div>
<button class="m-mall-claim" @click="openProduct(item.id)">{{ tab === 'limited' ? '抢单' : '领取' }}</button>
</article>
</div> </div>
</AsyncState> </AsyncState>
</section> </section>
@@ -76,7 +61,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ChevronForwardOutline, ImageOutline, SearchOutline } from '@vicons/ionicons5'; import { ImageOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api'; import { errorText, money, request, safeImage } from '../api';
@@ -89,29 +74,10 @@
const error = ref(''); const error = ref('');
const products = ref<any[]>([]); const products = ref<any[]>([]);
const product = ref<any>(); const product = ref<any>();
const keyword = ref('');
const submittedKeyword = ref('');
const tab = ref<'recommended' | 'limited'>('recommended');
let listController: AbortController | undefined; let listController: AbortController | undefined;
const isLimited = (item: any) => item.is_limited === true; function openSupport() {
const visibleProducts = computed(() => { router.push({ name: 'MobileSupport' });
let items = products.value;
if (tab.value === 'limited') {
items = items.filter(isLimited);
} else {
const recommended = items.filter((item) => item.is_recommended && !isLimited(item));
items = recommended.length ? recommended : items.filter((item) => !isLimited(item));
}
const query = submittedKeyword.value.toLocaleLowerCase('zh-CN');
return query ? items.filter((item) => String(item.name || '').toLocaleLowerCase('zh-CN').includes(query)) : items;
});
function applySearch() {
submittedKeyword.value = keyword.value;
}
function openProduct(id: number) {
router.push(`/app/mall/${id}`);
} }
async function loadList() { async function loadList() {
listController?.abort(); listController?.abort();
+1 -1
View File
@@ -5,7 +5,7 @@
<AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load"> <AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load">
<template v-if="intro"> <template v-if="intro">
<header class="m-platform-intro-head"> <header class="m-platform-intro-head">
<span>YUEXUANLI</span> <span>悦选</span>
<h2>{{ intro.title }}</h2> <h2>{{ intro.title }}</h2>
<p>{{ intro.summary }}</p> <p>{{ intro.summary }}</p>
<div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div> <div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div>
+209 -39
View File
@@ -16,7 +16,7 @@
v-for="item in services" v-for="item in services"
:key="item.id" :key="item.id"
class="m-service-card" class="m-service-card"
@click="$router.push(`/app/support/${item.id}`)" @click="openService(item)"
> >
<div class="m-service-avatar" <div class="m-service-avatar"
><img ><img
@@ -35,21 +35,23 @@
</div> </div>
</section> </section>
<section v-else-if="useThirdPartyChat" class="m-third-party-launch">
<PageHeader title="在线客服" />
<div class="m-third-party-launch__content">
<h2>在线客服</h2>
<p>聊天窗口将在当前页面内打开。</p>
<button type="button" class="m-primary-btn" @click="openThirdPartyChat">打开在线客服</button>
</div>
</section>
<section v-else class="m-chat"> <section v-else class="m-chat">
<PageHeader :title="current?.name || '在线客服'" /> <PageHeader :title="current?.name || '在线客服'" />
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div> <div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
<div v-if="chatLoading" class="m-chat-empty"> <div v-if="connectionState !== 'online' || chatError" :class="['m-chat-connection', `is-${connectionState}`]" role="status" aria-live="polite">
<n-icon :component="ChatbubbleEllipsesOutline" /> <span>{{ chatError || connectionStatusText }}</span>
<h2>正在连接客服</h2> <button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
<p>首次连接会自动建立好友关系</p>
</div> </div>
<div v-else-if="chatError" class="m-chat-empty"> <div ref="messageList" class="m-chat-messages">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>连接失败</h2>
<p class="m-form-error">{{ chatError }}</p>
<button class="m-primary-btn" @click="connectChat">重新连接</button>
</div>
<div v-else ref="messageList" class="m-chat-messages">
<div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]"> <div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
<div class="m-chat-avatar" aria-hidden="true"> <div class="m-chat-avatar" aria-hidden="true">
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" /> <img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
@@ -59,8 +61,11 @@
</div> </div>
<div class="m-chat-message"> <div class="m-chat-message">
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]"> <div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" /> <div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video> <span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
</div>
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
<p v-if="message.text">{{ message.text }}</p> <p v-if="message.text">{{ message.text }}</p>
</div> </div>
<small> <small>
@@ -70,7 +75,7 @@
</div> </div>
</div> </div>
<div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div> <div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div>
<div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 与您建立了会话</div> <div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 客服会话已打开</div>
<div class="m-chat-system-row"> <div class="m-chat-system-row">
<div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true"> <div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true">
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" /> <img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
@@ -78,7 +83,7 @@
</div> </div>
<div class="m-chat-system-message"> <div class="m-chat-system-message">
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small> <small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
<p><b>【系统消息】</b>尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?</p> <p><b>【系统消息】</b>您好,我是{{ current?.name || '在线客服' }}。您可以先输入要咨询的问题,正在为您连接客服。</p>
</div> </div>
</div> </div>
<div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]"> <div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
@@ -90,8 +95,11 @@
</div> </div>
<div class="m-chat-message"> <div class="m-chat-message">
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]"> <div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" /> <div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video> <span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
</div>
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
<p v-if="message.text">{{ message.text }}</p> <p v-if="message.text">{{ message.text }}</p>
</div> </div>
<small> <small>
@@ -103,12 +111,12 @@
</div> </div>
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }"> <div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
<form class="m-chat-input" @submit.prevent="sendMessage"> <form class="m-chat-input" @submit.prevent="sendMessage">
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" /> <input v-model="draft" :disabled="sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
<button <button
v-if="draft.trim()" v-if="draft.trim()"
class="m-chat-send" class="m-chat-send"
type="submit" type="submit"
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending" :disabled="!chat || connectionState !== 'online' || sending || mediaSending"
>{{ sending ? '发送中' : '发送' }}</button> >{{ sending ? '发送中' : '发送' }}</button>
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools"> <button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
<img :src="mobileAssets.chatMore" alt="" /> <img :src="mobileAssets.chatMore" alt="" />
@@ -117,8 +125,8 @@
<div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div> <div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div>
<div v-if="toolsOpen && !emojiOpen" class="m-chat-tools"> <div v-if="toolsOpen && !emojiOpen" class="m-chat-tools">
<button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button> <button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button>
<button type="button" :disabled="mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button> <button type="button" :disabled="!chat || connectionState !== 'online' || mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
<button type="button" :disabled="mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button> <button type="button" :disabled="!chat || connectionState !== 'online' || mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button>
<input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" /> <input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" />
<input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" /> <input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
</div> </div>
@@ -145,9 +153,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { import {
ChatbubbleEllipsesOutline,
HappyOutline, HappyOutline,
HeadsetOutline, HeadsetOutline,
ImageOutline, ImageOutline,
@@ -157,9 +164,83 @@
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import { deviceID, errorText, request, safeImage } from '../api'; import { deviceID, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets'; import { mobileAssets } from '../assets';
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im'; import {
CustomerServiceChat,
type ChatMessage,
type CustomerServiceConnectionState,
type CustomerServiceSession,
} from '../im';
type ThirdPartyChatWidget = {
initialized: boolean;
configReady: boolean;
init: (config: { channelId: string; useCustomButton: boolean }) => void;
open: () => void;
close: () => void;
};
declare global {
interface Window {
ChatWidget?: ThirdPartyChatWidget;
__olonsChatWidgetScriptPromise?: Promise<void>;
__olonsChatWidgetConfigPromise?: Promise<void>;
}
}
const route = useRoute(); const route = useRoute();
const router = useRouter();
const isChat = computed(() => Boolean(route.params.id)); const isChat = computed(() => Boolean(route.params.id));
const useThirdPartyChat = import.meta.env.VITE_CUSTOMER_SERVICE_PROVIDER === 'third_party';
const thirdPartyChatChannelId = 'ebdf4b92054645d992ec9ede4ac19161';
const thirdPartyChatScriptUrl = 'https://47.115.208.214:1582/chat/widget.js';
function openService(item: any) {
if (useThirdPartyChat) {
void openThirdPartyChat();
return;
}
void router.push(`/app/support/${item.id}`);
}
function loadThirdPartyChatWidget() {
if (window.ChatWidget?.initialized) return Promise.resolve();
if (!window.__olonsChatWidgetScriptPromise) {
window.__olonsChatWidgetScriptPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = thirdPartyChatScriptUrl;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('客服插件加载失败,请稍后重试'));
document.head.appendChild(script);
});
}
return window.__olonsChatWidgetScriptPromise;
}
async function initializeThirdPartyChatWidget() {
if (!useThirdPartyChat) return;
try {
await loadThirdPartyChatWidget();
const widget = window.ChatWidget;
if (!widget) throw new Error('客服插件初始化失败,请刷新页面重试');
if (!widget.initialized) {
window.__olonsChatWidgetConfigPromise = new Promise<void>((resolve) => {
window.addEventListener('chatWidgetReady', () => resolve(), { once: true });
});
widget.init({ channelId: thirdPartyChatChannelId, useCustomButton: true });
}
if (widget.configReady) return;
await Promise.race([
window.__olonsChatWidgetConfigPromise,
new Promise<void>((_, reject) => window.setTimeout(() => reject(new Error('客服配置加载超时,请稍后重试')), 10000)),
]);
} catch (err) {
window.$message?.error(errorText(err));
throw err;
}
}
async function openThirdPartyChat() {
try {
await initializeThirdPartyChatWidget();
window.ChatWidget?.open();
} catch {
// initializeThirdPartyChatWidget already displays a user-facing error.
}
}
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const services = ref<any[]>([]); const services = ref<any[]>([]);
@@ -169,8 +250,13 @@
services.value.find((item) => String(item.id) === String(route.params.id)) || services.value.find((item) => String(item.id) === String(route.params.id)) ||
services.value[0] services.value[0]
); );
const chatLoading = ref(false);
const chatError = ref(''); const chatError = ref('');
const connectionState = ref<CustomerServiceConnectionState>('offline');
const connectionStatusText = computed(() => {
if (connectionState.value === 'connecting') return '正在连接客服…';
if (connectionState.value === 'reconnecting') return '连接已断开,正在重新连接并同步消息…';
return '连接已断开,请重新连接';
});
const messages = ref<ChatMessage[]>([]); const messages = ref<ChatMessage[]>([]);
const historyBoundarySeq = ref(0); const historyBoundarySeq = ref(0);
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value)); const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
@@ -179,6 +265,8 @@
const sending = ref(false); const sending = ref(false);
const mediaSending = ref(false); const mediaSending = ref(false);
const uploadProgress = ref(0); const uploadProgress = ref(0);
const failedMediaKeys = ref(new Set<string>());
const mediaRetries = ref<Record<string, number>>({});
const photoInput = ref<HTMLInputElement | null>(null); const photoInput = ref<HTMLInputElement | null>(null);
const videoInput = ref<HTMLInputElement | null>(null); const videoInput = ref<HTMLInputElement | null>(null);
const toolsOpen = ref(false); const toolsOpen = ref(false);
@@ -245,8 +333,8 @@
chat = null; chat = null;
messages.value = []; messages.value = [];
historyBoundarySeq.value = 0; historyBoundarySeq.value = 0;
chatLoading.value = false;
chatError.value = ''; chatError.value = '';
connectionState.value = 'offline';
draft.value = ''; draft.value = '';
sending.value = false; sending.value = false;
mediaSending.value = false; mediaSending.value = false;
@@ -259,7 +347,9 @@
disconnectChat(); disconnectChat();
if (!serviceID) return; if (!serviceID) return;
const version = connectionVersion; const version = connectionVersion;
chatLoading.value = true; connectionState.value = 'connecting';
chatError.value = '';
sessionStartedAt.value = new Date().toISOString();
try { try {
const session = await request<CustomerServiceSession>({ const session = await request<CustomerServiceSession>({
url: `/v1/customer-services/${serviceID}/session`, url: `/v1/customer-services/${serviceID}/session`,
@@ -267,11 +357,19 @@
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' }, data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
}); });
if (version !== connectionVersion) return; if (version !== connectionVersion) return;
const nextChat = new CustomerServiceChat(session, (rows) => { const nextChat = new CustomerServiceChat(
if (version !== connectionVersion) return; session,
messages.value = rows; (rows) => {
void scrollToBottom(); if (version !== connectionVersion) return;
}); messages.value = rows;
void scrollToBottom();
},
{
onConnectionState: (state) => {
if (version === connectionVersion) connectionState.value = state;
},
},
);
chat = nextChat; chat = nextChat;
await nextChat.connect(session.token); await nextChat.connect(session.token);
if (version !== connectionVersion) { if (version !== connectionVersion) {
@@ -289,12 +387,11 @@
chat?.disconnect(); chat?.disconnect();
chat = null; chat = null;
chatError.value = errorText(err); chatError.value = errorText(err);
} finally { connectionState.value = 'offline';
if (version === connectionVersion) chatLoading.value = false;
} }
} }
async function sendMessage() { async function sendMessage() {
if (!chat || sending.value || !draft.value.trim()) return; if (!chat || connectionState.value !== 'online' || sending.value || !draft.value.trim()) return;
const activeChat = chat; const activeChat = chat;
const version = connectionVersion; const version = connectionVersion;
const value = draft.value; const value = draft.value;
@@ -318,7 +415,7 @@
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
input.value = ''; input.value = '';
if (!file || !chat || mediaSending.value) return; if (!file || !chat || connectionState.value !== 'online' || mediaSending.value) return;
const activeChat = chat; const activeChat = chat;
const version = connectionVersion; const version = connectionVersion;
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024; const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
@@ -360,6 +457,26 @@
draft.value += emoji; draft.value += emoji;
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24); recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
} }
function mediaIdentity(message: ChatMessage) {
return `${message.from}:${message.seq}:${message.media_url || ''}`;
}
function mediaFailed(message: ChatMessage) {
return failedMediaKeys.value.has(mediaIdentity(message));
}
function mediaRenderKey(message: ChatMessage) {
const key = mediaIdentity(message);
return `${key}:${mediaRetries.value[key] || 0}`;
}
function markMediaFailed(message: ChatMessage) {
failedMediaKeys.value = new Set([...failedMediaKeys.value, mediaIdentity(message)]);
}
function retryMedia(message: ChatMessage) {
const key = mediaIdentity(message);
const nextFailures = new Set(failedMediaKeys.value);
nextFailures.delete(key);
failedMediaKeys.value = nextFailures;
mediaRetries.value = { ...mediaRetries.value, [key]: (mediaRetries.value[key] || 0) + 1 };
}
async function scrollToBottom() { async function scrollToBottom() {
await nextTick(); await nextTick();
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight; if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
@@ -379,9 +496,62 @@
}).format(new Date(value)); }).format(new Date(value));
} }
watch(() => route.params.id, (serviceID) => { watch(() => route.params.id, (serviceID) => {
if (useThirdPartyChat) {
if (!serviceID) void load();
return;
}
if (serviceID) void connectChat(); if (serviceID) void connectChat();
else disconnectChat(); else disconnectChat();
}, { immediate: true, flush: 'sync' }); }, { immediate: true, flush: 'sync' });
onMounted(load); onMounted(() => {
onBeforeUnmount(disconnectChat); if (!isChat.value) void load();
if (useThirdPartyChat) void initializeThirdPartyChatWidget().catch(() => undefined);
});
onBeforeUnmount(() => {
disconnectChat();
if (useThirdPartyChat && window.ChatWidget?.initialized) window.ChatWidget.close();
});
</script> </script>
<style scoped>
.m-third-party-launch {
display: flex;
width: 100%;
min-height: 100dvh;
flex-direction: column;
background: #fff;
}
.m-third-party-launch__content {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
text-align: center;
}
.m-third-party-launch__content h2,
.m-third-party-launch__content p {
margin: 0;
}
:global(.chat-widget-iframe-container.mobile) {
width: min(92vw, 380px) !important;
height: min(72dvh, 560px) !important;
max-height: calc(100dvh - 150px) !important;
top: auto !important;
right: 12px !important;
bottom: calc(84px + env(safe-area-inset-bottom)) !important;
left: auto !important;
border-radius: 14px !important;
box-shadow: 0 8px 28px rgb(0 0 0 / 20%) !important;
overflow: hidden !important;
}
:global(.chat-widget-iframe-container.mobile .chat-widget-iframe) {
border-radius: 14px !important;
}
</style>
+48 -158
View File
@@ -4,245 +4,135 @@ export interface PlatformIntroSection {
} }
export const platformIntroLead = export const platformIntroLead =
'本协议阐述之条款和条件适用于您使用本网站所提供的各种工具和服务(下称“服务”)。'; '欢迎使用悦选。请在使用服务前阅读以下条款,特别留意充值、订单、账户限制与提现规则。';
export const platformIntroSections: PlatformIntroSection[] = [ export const platformIntroSections: PlatformIntroSection[] = [
{ {
title: '一、接受条款', title: '一、服务与协议',
paragraphs: [ paragraphs: [
'通过《涛声》或 APP 进入即表示您同意自己已经与《涛声》(简称公司)订立本协议,且您将受本协议的条款和条件(“条款”)约束。公司可随时自行全权决定更改“条款”。如您不同意相关变更,必须停止使用“服务”。经修订的“条款”一经网站公布,立即自动生效。您应在第一次登录后仔细阅读修订后的“条款”,并有权选择停止继续使用“服务”;一旦您继续使用“服务”,则表示您已接受经修订的“条款”。当您与公司发生争议时,应以最新的服务协议为准。除另行明确声明外,任何使“服务”范围扩大或功能增强的新内容均受本协议约束。除非经公司的授权高层管理人员签订书面协议,本协议不得另行作出修订。', '悦选通过网站和 APP 提供相关服务。注册或使用服务前,请阅读并理解本协议;如不同意,请停止使用。服务内容或条款发生变更时,平台将在页面公布,您可选择是否继续使用。',
], ],
}, },
{ {
title: '二、谁可使用本网站', title: '二、使用资格',
paragraphs: [ paragraphs: [
'“服务”仅供能够根据相关法律订立具有法律约束力的合约的个人或公司使用。因此,您的年龄必须在十八周岁或以上,才可使用本公司服务。如不符合本项条件,请勿使用“服务”。公司可随时自行全权决定拒绝向任何人士提供“服务”。“服务”不会提供给被暂时或永久中止资格的会员。', '使用者须年满 18 周岁,并具备相应的民事行为能力。被暂停或终止使用资格的账户不得继续使用服务。',
], ],
}, },
{ {
title: '三、收费', title: '三、费用与充值',
paragraphs: [ paragraphs: [
'本公司承诺不会收取平台用户的任何费用,无需押金,无需保证金,并且不会以任何方式联系用户索要任何费用。本公司联系方式请认准在线客服。用户需要自行充值到平台里面,方可进行代购。', '平台不收取注册费、押金或保证金。参与需要支付的订单前,账户须有足够可用余额;充值是向账户存入资金,并非平台收取的服务费。请仅通过页面展示的官方流程操作,谨防冒名索款。',
], ],
}, },
{ {
title: '四、您的资料和提供买卖的物品', title: '四、注册资料与账户安全',
paragraphs: [ paragraphs: [
'“您的资料”包括您在注册、交易或列举物品过程中,在任何公开信息场合或通过任何电子邮件形式,向本公司或其他用户提供的任何资料,包括数据、文本、软件、音乐、声响、照片、图画、影像、词句或其他材料。您应对“您的资料”真实性负全部责任,而本公司仅作为您在网上发布和刊登“您的资料”的被动渠道。', '注册时请提供真实、准确、完整的资料,并在信息变化后及时更新。请妥善保管账户和密码;发现未经授权的登录或操作时,及时修改密码并联系客服。',
'但是,倘若本公司认为您的信息可能使本公司承担任何法律或道义上的责任,或可能使本公司(全部或部分地)失去本公司的互联网服务供应商或其他供应商的服务,或您未在公司规定的期限内登录或再次登录网站,则本公司可自行全权决定对您的信息采取本公司认为必要或适当的任何行动,包括但不限于删除该类资料。您特此保证,您对提交的资料拥有全部权利,包括全部版权。您确认,网站没有责任去认定或决定您提交给本公司的信息哪些是应当受到保护的;对享有“服务”的其他用户使用您的信息,本公司也不必负责。', '如资料无法核实,或账户存在违规、欺诈风险,平台可按规则采取核验、限制或终止服务等措施。',
], ],
}, },
{ {
title: '五、注册义务', title: '五、信息与内容规范',
paragraphs: [ paragraphs: [
'如您在公司网站注册,您必须同意:', '您应对提交、发布的信息负责,不得提供虚假或侵权内容,不得发布违法、有害、骚扰性信息,或利用服务发送垃圾信息、收集他人资料、传播恶意程序。平台可依法处理违规内容。',
'(一)根据公司网址所刊载的会员信息表格的要求,提供关于您或贵公司的真实、准确、完整和反映当前情况的信息;',
'(二)维持并及时更新会员资料,使其保持真实、准确、完整和反映当前情况。倘若您提供任何不真实、不准确、不完整或不能反映当前情况的信息,或公司有合理理由怀疑该类资料不真实、不准确、不完整或不能反映当前情况的,公司有权暂停或终止您的注册身份及信息,并拒绝您在目前或将来对“服务”(或其任何部分)任何形式的使用。如您代表一家公司或其他法律主体在本公司登记,则您必须声明和保证,您有权使该公司或其他法律主体受本协议“条款”约束。',
], ],
}, },
{ {
title: '六、会员注册名、密码和保密', title: '六、禁止交易的物品',
paragraphs: [ paragraphs: [
'登记过程中,您将选择会员注册名和密码。您须自行负责对您的会员注册名和密码保密,且须对您在会员注册名和密码下发生的所有活动承担责任。您同意:', '不得通过平台发布或交易法律法规禁止的物品,以及平台规则明确不支持的物品。',
'(一)如发现任何人未经授权使用您的会员注册名或密码,或发生违反保密规定的任何其他情况,您会立即通知公司;',
'(二)确保您在每个上网时段结束时,以正确步骤离开网站。网站不能也不会对因您未能遵守本款规定而发生的任何损失或损毁负责。',
], ],
}, },
{ {
title: '七、关于您的信息的规则', title: '七、您提交的资料',
paragraphs: [ paragraphs: [
'您同意,您的个人信息和您提供在公司网站上交易的任何“资料”(泛指一切可供依法交易的、有形的或无形的、以各种形态存在的某种具体物品、权利、利益、票据、证券、服务或行为。本协议中“物品”一词均含此义):', '您应确保有权提交相关文字、图片等资料,并允许平台在提供、展示和维护服务所必需的范围内使用。个人信息的处理以隐私政策为准;请勿在公开区域披露敏感信息。',
'(一)不会有欺诈成分,与售卖伪造或盗窃无涉;',
'(二)不会侵犯任何第三者对该物品享有的物权,或版权、专利、商标、商业秘密或其他知识产权、隐私权、名誉权;',
'(三)不会违反任何法律、法规、条例或规章,包括但不限于关于规范出口管理、贸易配额、保护消费者、不正当竞争或虚假广告的法律、法规、条例或规章;',
'(四)不会含有诽谤(包括商业诽谤)、非法恐吓或非法骚扰的内容;',
'(五)不会含有淫秽或任何儿童色情内容;',
'(六)不会含有蓄意毁坏、恶意干扰、秘密截取或侵占任何系统、数据或个人资料的病毒、伪装破坏程序、电脑蠕虫、定时程序炸弹或其他电脑程序;',
'(七)不会直接或间接与下述各项货物或服务连接,或包含对下述各项货物或服务的描述:1. 本协议项下禁止的货物或服务;2. 您无权连接或包含的货物或服务;',
'(八)不会含有诽谤(包括商业诽谤)、非法恐吓或非法骚扰的内容;',
'(九)不会在与任何连锁信件、大量胡乱邮寄的电子邮件、滥发电子邮件或任何复制或多余的信息有关的方面使用“服务”;',
'(十)不会未经其他人士同意,利用“服务”收集其他人士的电子邮件地址及其他信息;',
'(十一)不会利用“服务”制作虚假的电子邮件地址,或以其他形式试图在发送人的身份或信息来源方面误导其他人士。',
], ],
}, },
{ {
title: '八、被禁止物品', title: '八、服务中断与账户限制',
paragraphs: [ paragraphs: [
'您不得在本公司网站公布或通过本公司网站买卖:', '网络故障、系统维护或其他客观原因可能影响服务。平台将尽力恢复并处理异常。对违反协议、涉嫌欺诈或长期未使用的账户,平台可采取警告、限制或终止服务等措施;涉及账户资金的处理应以实际订单记录和适用规则为依据。',
'(一)可能使本公司违反任何相关法律、法规、条例或规章的任何物品;',
'(二)公司认为应禁止或不适合通过本网站买卖的任何物品。',
], ],
}, },
{ {
title: '九、您授予本公司的许可使用权', title: '九、订单规则',
paragraphs: [ paragraphs: [
'您授予本公司独家的、全球通用的、永久的、免费的许可使用权利(并有权在多个层面对该权利进行再授权),使本公司有权全部或部分地使用、复制、修订、改写、发布、翻译、分发、执行和展示“您的资料”或制作其派生作品,和/或以现在已知或日后开发的任何形式、媒体或技术,将“您的资料”纳入其他作品内。', '订单类型、金额、处理方式和期限以领取或匹配时的页面信息为准。自动模式仅匹配可用余额内的订单;手动模式可能匹配高于当前余额的订单,需由用户确认,并在余额足够后提交。',
'原规则载明:普通订单可能高于账户本金余额,最多 2 个订单统一结算;促销联单、福利订单完成后统一返款,福利订单须在 3 小时内完成。团队任务须由相关成员共同完成。请在接受订单前核对金额、期限和结算条件。',
'原规则还载明:违规可能涉及 50% 的违约金、风险金或信用分调整;信用分需在 25 小时内补齐。此类费用或限制须有明确适用条件与处理依据,遇到争议请先联系客服核实。',
], ],
}, },
{ {
title: '十、隐私', title: '十、提现与账户限制',
paragraphs: [ paragraphs: [
'尽管有第九条所规定的许可使用权,我们将仅根据本公司的隐私声明使用您提供的信息。本公司隐私声明的全部条款属于本协议的一部分,因此,您必须仔细阅读。请注意,您一旦自愿地在公司网站披露您的信息,该类资料即可能被其他人士获取和使用。', '原规则要求提现时信用分为 100 分,并要求账户完成与账户金额相对应的流水。一张银行卡不得绑定多个账户;重复注册或绑定可能导致账户限制、资金冻结 90 天或永久封号。请在充值和接单前确认提现条件。',
], ],
}, },
{ {
title: '十一、终止或访问限制', title: '十一、第三方内容与链接',
paragraphs: [ paragraphs: [
'您同意,在公司未向您收费的情况下,本公司可自行全权决定以任何理由,包括但不限于公司认为您已违反本协议的字面意义和精神,或您以不符合本协议字面意义和精神的方式行事,或您在超过 90 天的时间内未以您的账号及密码登录网站,终止您的“服务”密码、账户(或其任何部分)或您对“服务”的使用,并删除和丢弃您在使用“服务”中提交的信息。', '第三方广告、网站或服务由相应提供方负责。访问外部链接、与第三方交易前,请自行核实信息和风险。',
'您同意,在公司向您收费的情况下,公司应基于合理的怀疑且经电子邮件通知的情况下实施上述终止服务的行为。公司同时可自行全权决定,在发出通知或不发出通知的情况下,随时停止提供“服务”或其任何部分。您同意,根据本协议的任何规定终止您使用“服务”之措施可在不发出事先通知的情况下实施,并承认和同意,公司可立即使您的账户无效,或撤销您的账户以及在您的账户内的所有相关资料和档案,和/或禁止您进一步接入该等档案或“服务”。',
'账号终止后,公司没有义务为您保留原账号中或与之相关的任何信息,或转发任何未曾阅读或发送的信息给您或第三方。此外,您同意,公司不会就终止您接入“服务”而对您或任何第三者承担任何责任。第 12、13、14 和 22 条应在本协议终止后继续有效。',
], ],
}, },
{ {
title: '十二、违反规则会有什么后果?', title: '十二、法律适用与争议处理',
paragraphs: [ paragraphs: [
'在不限制其他补救措施的前提下,发生下述任一情况,本公司可立即发出警告,暂时终止、永久终止或终止您的会员资格,删除您的任何现有产品信息,以及您在网站上展示的任何其他资料:', '使用服务时应遵守适用法律法规。本协议适用中华人民共和国大陆地区法律;部分条款无效,不影响其他条款的效力。发生争议时,可先通过在线客服沟通。',
'(一)您违反本协议;',
'(二)本公司无法核实或鉴定您向本公司提供的任何资料;',
'(三)本公司相信您的行为可能会使您、本公司用户或通过本公司或本公司网站提供服务的第三者服务供应商发生任何法律责任。在不限制任何其他补救措施的前提下,倘若发现您从事涉及本公司网站的诈骗活动,公司可暂停或终止您的账户。',
], ],
}, },
{ {
title: '十三、服务“按现状”提供', title: '十三、通知与其他事项',
paragraphs: [ paragraphs: [
'本公司会尽一切努力使您在使用公司网站的过程中得到良好体验。遗憾的是,本公司不能随时预见到任何技术上的问题或其他困难。该等困难可能会导致数据损失或其他服务中断。为此,您明确理解和同意,您使用“服务”的风险由您自行承担。“服务”以“按现状”和“按可得到”的基础提供。公司明确声明不作出任何种类的所有明示或暗示的保证,包括但不限于关于适销性、适用于某一特定用途和无侵权行为等方面的保证。', '平台可通过站内消息、页面公告或您登记的联系方式发送服务通知。因不可抗力造成服务延迟或中断时,平台将尽力减少影响。',
'公司对下述内容不作保证:(一)“服务”会符合您的要求;(二)“服务”不会中断,且适时、安全和不带任何错误;(三)通过使用“服务”而可能获取的结果将是准确或可信赖的;(四)您通过“服务”而购买或获取的任何产品、服务、资料或其他材料的质量将符合您的预期。',
'通过使用“服务”而下载或以其他形式获取任何材料是由您自行全权决定进行的,且与此有关的风险由您自行承担。对于因您下载任何该等材料而发生的电脑系统损毁或数据损失,您将自行承担责任。您从公司或通过“服务”获取的任何口头或书面意见或资料,均不产生未在本协议内明确载明的任何保证。',
],
},
{
title: '十四、责任范围',
paragraphs: [
'您明确理解和同意,公司不对因下述任一情况而发生的任何损害赔偿承担责任,包括但不限于利润、商誉、使用、数据等方面的损失或其他无形损失的损害赔偿(无论公司是否已被告知该等损害赔偿的可能性):',
'(一)使用或未能使用“服务”;',
'(二)因通过或从“服务”购买或获取任何货物、样品、数据、资料或服务,或通过或从“服务”接收任何信息或缔结任何交易所产生的获取替代货物和服务的费用;',
'(三)未经批准接入或更改您的传输资料或数据;',
'(四)任何第三者对“服务”的声明或关于“服务”的行为;',
'(五)因任何原因而引起的与“服务”有关的其他事宜,包括疏忽。',
],
},
{
title: '十五、订单规则',
paragraphs: [
'(一)订单有多个类型,每一类型针对人群都有所不同。',
'(二)普通订单为常规订单,普通订单偶尔有超出会员账户本金余额。平台最多 2 个订单统一结算,客户接取此类订单时需要注意规则。以上所有订单类型适用于个人与团队。(注:团队任务需要团队成员所有人完成任务,否则视为未完成任务。)',
'(三)违规者自行承担后果,包括但不限于违约金 50%、风险金等。',
'(四)促销联单和福利订单是统一完成后返款。',
'(五)福利单需要在规定的三个小时内完成。',
'(六)出现违规操作、超时等,可能导致信用分扣除。信用分需要在 25 小时之内补齐,如超过 25 小时没有补齐,会导致用户信用分再次下降。',
],
},
{
title: '十六、赔偿',
paragraphs: [
'您同意,因您违反本协议或经在此提及而纳入本协议的其他文件,或因您违反法律或侵害第三方权利,而使第三方对本公司及其子公司、分公司、董事、职员、代理人提出索赔要求(包括司法费用和其他专业人士的费用),您必须赔偿本公司及其子公司、分公司、董事、职员、代理人,使其免遭损失。',
],
},
{
title: '十七、遵守法律',
paragraphs: ['您应遵守与您使用“服务条款”以及与您提供商贸信息有关的所有相关法律、法规、条例和规章。'],
},
{
title: '十八、代理关系',
paragraphs: [
'您与本公司仅为独立订约人关系。本协议无意结成或创设任何代理、合伙、合营、雇用与被雇用或特许权授予与被授予关系。',
],
},
{
title: '十九、广告和金融服务',
paragraphs: [
'您与在“服务”上或通过“服务”物色的刊登广告人士通讯、进行业务往来或参与其推广活动,包括就相关货物或服务付款和交付,以及与该等业务往来相关的任何其他条款、条件、保证或声明,仅限于在您和该刊登广告人士之间发生。您同意,对于因任何该等业务往来或因在“服务”上出现该等刊登广告人士而发生的任何种类的损失或损毁,本公司无需负责或承担任何责任。',
'您如打算通过“服务”创设或参与与任何公司、股票行情、投资或证券有关的任何服务,或通过“服务”收取或要求与任何公司、股票行情、投资或证券有关的任何新闻信息、警戒性信息或其他资料,敬请注意,本公司不会就通过“服务”传送的任何该等资料的准确性、有用性、可用性或可获利性负责或承担任何责任,且不会对根据该等资料而作出的任何交易或投资决策负责或承担任何责任。',
],
},
{
title: '二十、链接',
paragraphs: [
'“服务”或第三者均可提供与其他万维网网站或资源的链接。由于本网站并不控制该等网站和资源,您承认并同意,本公司并不对该等外在网站或资源的可用性负责,且不认可该等网站或资源上或可从该等网站或资源获取的任何内容、宣传、产品、服务或其他材料,也不对其负责或承担任何责任。',
'您进一步承认和同意,对于任何因使用或信赖从此类网站或资源上获取的内容、宣传、产品、服务或其他材料而造成或声称造成的任何直接或间接损失,本公司均不承担责任。',
],
},
{
title: '二十一、其他规定',
paragraphs: [
'本协议构成您和公司之间的全部协议,规定了您对“服务”的使用,并取代您和公司先前订立的任何书面或口头协议。本协议各方面应受中华人民共和国大陆地区法律的管辖。倘若本协议任何规定被裁定为无效或不可强制执行,该项规定应被撤销,而其余规定应予执行。条款标题仅为方便参阅而设,并不以任何方式界定、限制、解释或描述该条款的范围或限度。本公司未就您或其他人士的某项违约行为采取行动,并不表明本公司撤回就任何继后或类似的违约事件采取行动的权利。',
],
},
{
title: '二十二、通知',
paragraphs: [
'除非另有明确规定,任何通知应以电子邮件形式发送。就本公司而言,电子邮件地址为 dovekf@acca.com.cn;就您而言,发送到您在登记过程中向本公司提供的电子邮件地址,或有关方指明的其他地址。在电子邮件发出二十四(24)小时后,通知应被视为已送达,除非发送人被告知相关电子邮件地址已作废。',
'或者,本公司可通过邮资预付挂号邮件并要求回执的方式,将通知发到您在登记过程中向本公司提供的地址。在该情况下,在付邮当日三(3)天后通知被视为已送达。',
],
},
{
title: '二十三、不可抗力',
paragraphs: [
'对于因本公司合理控制范围以外的原因,包括但不限于自然灾害、罢工或骚乱、物质短缺或定量配给、暴动、战争行为、政府行为、通讯或其他设施故障或严重伤亡事故等,致使本公司延迟或未能履约的,本公司不对您承担任何责任。',
],
},
{
title: '二十四、转让',
paragraphs: ['本公司转让本协议无需经您同意。'],
},
{
title: '二十五、其他规定',
paragraphs: [
'本协议构成您和本公司之间的全部协议,规定了您对“服务”的使用,并取代您和本公司先前订立的任何书面或口头协议。本协议各方面应受中华人民共和国大陆地区法律的管辖。倘若本协议任何规定被裁定为无效或不可强制执行,该项规定应被撤销,而其余规定应予执行。条款标题仅为方便参阅而设,并不以任何方式界定、限制、解释或描述该条款的范围或限度。本公司未就您或其他人士的某项违约行为采取行动,并不表明本公司撤回就任何继后或类似的违约事件采取行动的权利。',
],
},
{
title: '二十六、平台提现要求',
paragraphs: [
'平台提现硬性要求信用分值为 100 分。',
'温馨提示:同一个人银行卡信息请勿重复注册抢单,经发现将会冻结账号 ID。',
'个人多账户代购会导致商家封店,影响商家信誉度以及销量单作废。本平台禁止一卡绑定多账户;一卡绑定多账户将给予资金冻结 90 天、账号永久封号处理。本平台为防止恶意洗钱或套现等不正当行为,兼职会员账户完成账户金额相对应的流水即可全额提款。',
], ],
}, },
]; ];
export const platformFAQs: PlatformIntroSection[] = [ export const platformFAQs: PlatformIntroSection[] = [
{ {
title: '1. 关于充值', title: '1. 如何充值?',
paragraphs: [ paragraphs: [
'充值在“我的”—“我的钱包”界面进行。点击“充值”按钮,然后填写转账人的姓名和需转账的金额,点击去转账进行汇款充值。按照平台提供的账户汇款后,务必提交转账到账成功截图,然后点击“提交”即可。', '进入“我的”→“我的钱包”→“充值”,填写付款人姓名和金额,按页面指引转账并上传成功凭证。付款姓名、金额应与提交信息一致。转账前务必核对当次页面展示的收款信息;有疑问先联系在线客服,切勿向来源不明的账户付款。',
'为保证充值快速到账,烦请您所填转账人姓名和汇款金额一定要和汇款时一致。如在充值过程中遇到无法解决的问题,请及时联系人工客服。由于充值信息量过大,充值前请务必仔细核对本平台的账户卡号。本平台不定期更换账号,每一次充值都需要跟客服获取商家卡号;充错卡号不予在平台上充值,后果自行负责。有不明白的问题可以咨询在线客服。',
], ],
}, },
{ {
title: '2. 关于提现', title: '2. 如何提现?',
paragraphs: [ paragraphs: [
'在提现之前,烦请您先在 APP 绑定提现信息。在平台工作时间内,可在“我的”—“我的钱包”界面进行提现。点击“提现”按钮,输入您想要提现的金额和提现密码即可进行提现。21 点之前提现都是 0—5 分钟到账,繁忙高峰期 30 分钟内到账;21 点以后提现 T+1 到账。', '先绑定取出信息,再到“我的钱包”选择“提现”,填写金额和支付密码。原说明预计:21:00 前约 0—5 分钟到账,高峰期可能延至 30 分钟;21:00 后为次日到账。实际时间以银行处理和页面状态为准。',
], ],
}, },
{ {
title: '3. 关于领取', title: '3. 如何领取订单?',
paragraphs: [ paragraphs: [
'在 APP 内完善好个人真实资料和真实收货地址,因为系统审核必须填写。客户需绑定自己可以接受短信、开通手机银行的银行卡,方便查收款项和对商家卡号进行转账。已经充值到账后,即可进行代购。在代购成功后,系统会根据客户会员等级的不同分配不同的拍单时间和金额,具体参考会员等级。正常会员系统会在 15—20 分钟内派单。', '完善真实资料和收款信息后,可在领取页查看订单。自动模式仅匹配可用余额内的订单;手动模式由客服匹配,用户需自行确认。原说明称正常会员约 15—20 分钟派单,具体以页面状态为准。',
], ],
}, },
{ {
title: '4. 关于订单冻结', title: '4. 订单冻结后怎么办?',
paragraphs: [ paragraphs: [
'平台根据客户的账户金额和云数据派单,偶尔订单会出现大过账户本金的情况。客户只需补全账户负数,系统即可第一时间结算。订单未完成将被冻结,30 天自动解冻并扣除 30% 的违约金;继续完成未完成的商品或者订单,返本金到您绑定的账户银行卡内或平台账户内。未能理解的可以咨询在线客服。', '请先在订单详情核对冻结原因、待处理金额和期限,再联系在线客服。原规则载明:未完成订单 30 天后自动解冻,并可能扣除 30% 违约金;继续完成订单后返还本金。涉及扣款或冻结时,请先核实适用依据,不要仅凭口头要求转账。',
], ],
}, },
{ {
title: '5. 关于代理', title: '5. 推荐奖励如何计算?',
paragraphs: [ paragraphs: [
'平台用户推荐新人即可成为平台代理,同时可以获得额外的动态奖励。直推一级用户奖励为一级用户每天所得佣金的 16%,二级用户奖励为二级用户每天所得佣金的 8%,三级用户奖励为三级用户每天所得佣金的 4%。(如您邀请的用户当日佣金是 100 元,您可以获取其佣金 16% 的奖励,即 16 元;二级为 8%。)详情可咨询在线客服。', '原规则按受邀用户当日佣金计算三级推荐奖励:一级 16%、二级 8%、三级 4%。奖励记录和到账情况请以账户页面为准。',
], ],
}, },
{ {
title: '6. 大额返款', title: '6. 大额返款到哪里?',
paragraphs: ['平台金额大于十五万时,统一返款至平台账户,方便会员自由运用资金。'], paragraphs: ['原规则载明,超过 15 万元的款项统一返至平台账户;具体以订单和账户记录为准。'],
}, },
{ {
title: '7. 领取时间', title: '7. 什么时候可以领取?',
paragraphs: ['平台运营时间为每天 9:00—21:00,用户在平台运营时间内均可进行代购。'], paragraphs: ['原规则载明,平台运营及领取时间为每日 9:00—21:00。'],
}, },
{ {
title: '8. 订单超时', title: '8. 订单超时怎么办?',
paragraphs: ['领取订单超出一个小时未能完成,系统即可认定超时,需通过超时冲单取代。'], paragraphs: [
'原规则载明,领取后超过 1 小时未完成会被认定为超时。请在订单详情查看处理提示,并联系在线客服核实后续操作。',
],
}, },
]; ];
+1 -1
View File
@@ -208,7 +208,7 @@ window.addEventListener('vite:preloadError', (event) => {
router.onError((error) => reloadAfterStaleChunk(error)); router.onError((error) => reloadAfterStaleChunk(error));
router.afterEach((route) => { router.afterEach((route) => {
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons'; document.title = route.meta.title ? `${String(route.meta.title)} - 悦选` : '悦选';
}); });
export default router; export default router;