Compare commits
31
Commits
715740fc30
...
pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56b84bb10 | ||
|
|
8ce67648b1 | ||
|
|
9bb479b506 | ||
|
|
79c502cb20 | ||
|
|
47b545d050 | ||
|
|
78165b7a15 | ||
|
|
6954d375b5 | ||
|
|
975a2a2ed2 | ||
|
|
5f85472757 | ||
|
|
92b5656f3f | ||
|
|
cdebd0a404 | ||
|
|
ba87d70478 | ||
|
|
db1367b8fe | ||
|
|
db18514e6d | ||
|
|
c39ec78e89 | ||
|
|
e6c421ac3e | ||
|
|
74785f948b | ||
|
|
f2f4720340 | ||
|
|
dffd47294e | ||
|
|
950890a734 | ||
|
|
179a9bdd16 | ||
|
|
aa0e17f1f1 | ||
|
|
47bb4fe2ed | ||
|
|
355d59813b | ||
|
|
04a3a0f538 | ||
|
|
1c11c0fa82 | ||
|
|
5e0abedbcc | ||
|
|
21371061ed | ||
|
|
319768bd82 | ||
|
|
d8facff163 | ||
|
|
2e47dd3935 |
@@ -40,7 +40,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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:$SHORT_SHA"
|
||||
|
||||
@@ -64,5 +64,18 @@ jobs:
|
||||
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login git.olns.shop -u admin --password-stdin"
|
||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd /opt/olns && docker compose pull olons-server && docker compose up -d --no-deps --wait olons-server"
|
||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" '
|
||||
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"
|
||||
'"'"'
|
||||
'
|
||||
|
||||
@@ -6,7 +6,9 @@ RUN corepack enable
|
||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
ARG VITE_CUSTOMER_SERVICE_PROVIDER=
|
||||
ENV VITE_PUBLIC_PATH=/
|
||||
ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}
|
||||
RUN pnpm run build
|
||||
|
||||
# ---- Stage 2: build the Go backend ----
|
||||
|
||||
@@ -71,7 +71,7 @@ X-Request-ID: <request id>
|
||||
X-Device-ID: <optional device id>
|
||||
```
|
||||
|
||||
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"allow_dev_header_auth": true,
|
||||
"required_api_key": false
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"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>` | 受保护接口必填 | 登录成功后取得的用户会话 |
|
||||
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
|
||||
|
||||
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。
|
||||
服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
|
||||
|
||||
### 3.3 登录会话
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ type profileRepository struct {
|
||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
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")
|
||||
}
|
||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -47,7 +46,7 @@ var (
|
||||
|
||||
type Repository interface {
|
||||
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
|
||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, 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
|
||||
}
|
||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
TenantID: 0,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
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 {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
||||
account, err := s.repository.AccountByMobile(ctx, phone)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
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 {
|
||||
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) {
|
||||
|
||||
@@ -14,7 +14,7 @@ type fakeRepository struct{}
|
||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
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")
|
||||
}
|
||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||
|
||||
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
||||
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||
}
|
||||
|
||||
func (a *Authenticator) DefaultTenantID() int64 {
|
||||
return a.cfg.DefaultTenantID
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||
if err := a.ValidateAPIKey(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.AllowDevHeaderAuth {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +67,10 @@ type AuthConfig struct {
|
||||
APIKeys []string `json:"api_keys"`
|
||||
SessionKeyPattern string `json:"session_key_pattern"`
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||
FinanceMasterKey string `json:"finance_master_key"`
|
||||
FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"`
|
||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||
RequiredAPIKey bool `json:"required_api_key"`
|
||||
}
|
||||
@@ -86,7 +86,11 @@ func Load() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey)
|
||||
applyEnv(cfg)
|
||||
if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) {
|
||||
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey)
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,14 +110,13 @@ func defaultConfig() *Config {
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "olons_server:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
|
||||
IM: IMConfig{
|
||||
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",
|
||||
TenantCode: "allrich", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
|
||||
TenantCode: "default", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
|
||||
InternalService: "olons-server", Timeout: 8 * time.Second,
|
||||
},
|
||||
ReadTimeout: 8 * time.Second,
|
||||
@@ -133,10 +136,12 @@ func applyEnv(cfg *Config) {
|
||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||
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.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_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.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||
ApplyUploadEnv(&cfg.Upload)
|
||||
@@ -177,9 +182,6 @@ func (c *Config) validate() error {
|
||||
if c.Auth.SessionTTLSeconds <= 0 {
|
||||
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
||||
}
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
if err := ValidateUpload(c.Upload); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -261,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) {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
if err := cfg.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.DefaultTenantID != 0 {
|
||||
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
|
||||
if cfg.IM.TenantCode != "default" {
|
||||
t.Fatalf("unexpected default IM tenant: %q", cfg.IM.TenantCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +24,24 @@ func TestConfigRejectsOtherDatabase(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
+40
-45
@@ -35,7 +35,6 @@ const (
|
||||
var (
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
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 {
|
||||
@@ -62,6 +61,7 @@ type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
aead cipher.AEAD
|
||||
legacyAEADs []cipher.AEAD
|
||||
lookupKey []byte
|
||||
ready bool
|
||||
now func() time.Time
|
||||
@@ -71,7 +71,6 @@ type PaymentAccountInput struct {
|
||||
ID int64 `json:"id"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
AccountNo string `json:"account_no"`
|
||||
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
@@ -84,7 +83,6 @@ type PaymentAccount struct {
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
IdentityNoMask string `json:"identity_no_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
@@ -99,7 +97,6 @@ type PaymentAccountDetail struct {
|
||||
ID int64 `json:"id"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
AccountNo string `json:"account_no"`
|
||||
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||
WechatQRURL string `json:"wechat_qr_url"`
|
||||
@@ -266,20 +263,39 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
||||
return service
|
||||
}
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
aead, err := newAEAD(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
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.ready = repository != nil && redisClient != nil
|
||||
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 {
|
||||
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||
_, _ = 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))
|
||||
for _, row := range rows {
|
||||
holderName, accountNo := row.HolderMask, row.AccountNoMask
|
||||
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
|
||||
digitalRMBCode := row.DigitalRMBCodeMask
|
||||
masked := true
|
||||
if s.ready {
|
||||
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
|
||||
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
|
||||
if holderErr == nil && accountErr == nil {
|
||||
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 value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
|
||||
digitalRMBCode = value
|
||||
@@ -337,7 +346,7 @@ func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID
|
||||
}
|
||||
items = append(items, PaymentAccountDetail{
|
||||
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
||||
IdentityNo: identityNo, AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
|
||||
AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
|
||||
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
|
||||
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||
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 {
|
||||
return store.PaymentAccountCreation{}, err
|
||||
}
|
||||
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
||||
if err != nil {
|
||||
return store.PaymentAccountCreation{}, err
|
||||
}
|
||||
accountCipher, err := s.encrypt(input.AccountNo)
|
||||
if err != nil {
|
||||
return store.PaymentAccountCreation{}, err
|
||||
@@ -406,8 +411,7 @@ func (s *Service) paymentAccountCreation(tenantID, memberID int64, input Payment
|
||||
return store.PaymentAccountCreation{
|
||||
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
||||
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
||||
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
|
||||
AccountNoCipher: accountCipher,
|
||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
|
||||
DigitalRMBCodeMask: digitalRMBCodeMask,
|
||||
@@ -454,13 +458,6 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
identityNo := ""
|
||||
if account.IdentityNoCipher != "" {
|
||||
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
}
|
||||
digitalRMBCode := ""
|
||||
if 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{
|
||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||
"bank_name": account.BankName, "holder_name": holderName,
|
||||
"identity_no": identityNo, "account_no": accountNo,
|
||||
"account_no": accountNo,
|
||||
"digital_rmb_code": digitalRMBCode,
|
||||
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||
@@ -849,10 +846,9 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||
input.BankName = strings.TrimSpace(input.BankName)
|
||||
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.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 ||
|
||||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
|
||||
@@ -865,7 +861,7 @@ func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||
|
||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||
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,
|
||||
WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||
@@ -1042,12 +1038,18 @@ func (s *Service) decrypt(value string) (string, error) {
|
||||
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||
return "", errors.New("encrypted value is invalid")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...)
|
||||
for _, aead := range aeads {
|
||||
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 "", errors.New("encrypted value cannot be decrypted")
|
||||
}
|
||||
|
||||
func (s *Service) lookupHash(value string) string {
|
||||
mac := hmac.New(sha256.New, s.lookupKey)
|
||||
@@ -1116,13 +1118,6 @@ func maskAccount(value string) string {
|
||||
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 {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
|
||||
@@ -135,22 +135,21 @@ func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
|
||||
|
||||
func TestNormalizePaymentAccount(t *testing.T) {
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
||||
AccountNo: "6222 0000-0000 1234",
|
||||
DigitalRMBCode: "1234 5678",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
||||
input.DigitalRMBCode != "12345678" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
||||
if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234",
|
||||
AccountNo: "6222000000001234",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
t.Fatalf("empty digital RMB code should be optional: %v", err)
|
||||
@@ -167,11 +166,10 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||
})
|
||||
holder, _ := service.encrypt("张三")
|
||||
identity, _ := service.encrypt("11010119900101123X")
|
||||
account, _ := service.encrypt("6222000000001234")
|
||||
digital, _ := service.encrypt("123456")
|
||||
repository.details = []store.PaymentAccount{{
|
||||
ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity,
|
||||
ID: 7, BankName: "中国工商银行", HolderCipher: holder,
|
||||
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
|
||||
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
|
||||
@@ -180,7 +178,7 @@ func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
||||
if err != nil {
|
||||
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].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
|
||||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
|
||||
@@ -188,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) {
|
||||
repository := &paymentAccountRepository{}
|
||||
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||
})
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -226,7 +249,7 @@ func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *
|
||||
t.Fatal(err)
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("unexpected masked fallback: %#v", items)
|
||||
}
|
||||
@@ -238,7 +261,7 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
||||
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||
})
|
||||
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
|
||||
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X",
|
||||
BankName: "中国工商银行", HolderName: "张三",
|
||||
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -247,6 +270,9 @@ func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -45,6 +45,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||
mux.HandleFunc("/v1/notices/pending", a.pendingNotice)
|
||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
|
||||
@@ -85,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||
return
|
||||
}
|
||||
tenantID := a.auth.DefaultTenantID()
|
||||
tenantID := int64(0)
|
||||
if _, public := publicContentKeys[key]; !public {
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
@@ -211,6 +211,27 @@ func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) pendingNotice(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.portal.PendingNotice(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
|
||||
const maxResponseBody = 2 << 20
|
||||
|
||||
const defaultIMTenantCode = "default"
|
||||
|
||||
type Error struct {
|
||||
Status int
|
||||
Code int
|
||||
@@ -105,6 +107,11 @@ type Conversation struct {
|
||||
CanSend bool `json:"can_send"`
|
||||
}
|
||||
|
||||
type DirectFriendResult struct {
|
||||
Relationship string `json:"relationship"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
func New(cfg config.IMConfig) *Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
@@ -181,9 +188,13 @@ func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, p
|
||||
}
|
||||
loginID := "login-" + requestID()
|
||||
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{
|
||||
"id": loginID, "scheme": "basic", "secret": secret,
|
||||
"login_type": 0, "tenant_code": c.cfg.TenantCode,
|
||||
"login_type": loginType, "tenant_code": c.cfg.TenantCode,
|
||||
}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,6 +234,18 @@ func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempot
|
||||
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) {
|
||||
var out Conversation
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// credentials. Callers may use this to repair credentials which have drifted
|
||||
// from the local encrypted copy, while leaving transport failures untouched.
|
||||
|
||||
@@ -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) {
|
||||
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" {
|
||||
@@ -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) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
|
||||
@@ -22,7 +22,7 @@ type Repository interface {
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
@@ -220,6 +220,16 @@ func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*N
|
||||
SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PendingNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||
row, err := s.repository.NextUnreadMemberNotice(ctx, tenantID, memberID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt,
|
||||
EndAt: row.EndAt, CreatedAt: row.CreatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
func (r fakeRepository) NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
if len(r.notices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -95,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
||||
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 {
|
||||
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 {
|
||||
@@ -112,11 +112,11 @@ type queryer interface {
|
||||
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
|
||||
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
|
||||
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) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func financeTimeTestDB(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set OLONS_TEST_MYSQL_DSN to run the finance time integration tests")
|
||||
}
|
||||
db, err := sqlx.Connect("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestFinanceRecordTimesUseCorrectSourceZone(t *testing.T) {
|
||||
db := financeTimeTestDB(t)
|
||||
// Temporary tables shadow only this connection's tables; no business rows are changed.
|
||||
for _, query := range []string{
|
||||
`CREATE TEMPORARY TABLE hg_payment_channel (id BIGINT,tenant_id BIGINT,name VARCHAR(64))`,
|
||||
`CREATE TEMPORARY TABLE hg_recharge_order (
|
||||
id BIGINT,tenant_id BIGINT,member_id BIGINT,recharge_no VARCHAR(64),channel_id BIGINT,
|
||||
request_amount BIGINT DEFAULT 50000,paid_amount BIGINT DEFAULT 50000,currency VARCHAR(8) DEFAULT 'CNY',
|
||||
voucher_attachment_ids JSON,recharge_status INT DEFAULT 3,remark VARCHAR(255) DEFAULT '',
|
||||
review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '',
|
||||
paid_at DATETIME,reviewed_at DATETIME,credited_at DATETIME,created_at DATETIME,updated_at DATETIME)`,
|
||||
`CREATE TEMPORARY TABLE hg_withdrawal_order (
|
||||
id BIGINT,tenant_id BIGINT,member_id BIGINT,withdrawal_no VARCHAR(64),payment_account_id BIGINT DEFAULT 1,
|
||||
channel_id BIGINT,request_amount BIGINT DEFAULT 50000,fee_amount BIGINT DEFAULT 0,actual_amount BIGINT DEFAULT 50000,
|
||||
currency VARCHAR(8) DEFAULT 'CNY',account_mask VARCHAR(64) DEFAULT '',bank_name VARCHAR(64) DEFAULT '',
|
||||
holder_mask VARCHAR(64) DEFAULT '',risk_result JSON,withdrawal_status INT DEFAULT 5,
|
||||
review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '',
|
||||
reviewed_at DATETIME,processed_at DATETIME,completed_at DATETIME,created_at DATETIME,updated_at DATETIME)`,
|
||||
} {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s := &Store{db: db}
|
||||
ctx := context.Background()
|
||||
for _, test := range []struct{ zone, createdAt string }{
|
||||
{"+00:00", "2026-10-22 15:59:00"},
|
||||
{"+08:00", "2026-10-22 23:59:00"},
|
||||
} {
|
||||
t.Run(test.zone, func(t *testing.T) {
|
||||
for _, query := range []string{`DELETE FROM hg_recharge_order`, `DELETE FROM hg_withdrawal_order`} {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hg_recharge_order
|
||||
(id,tenant_id,member_id,recharge_no,channel_id,created_at,updated_at,paid_at,reviewed_at,credited_at)
|
||||
VALUES(1,0,9,'RT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'),
|
||||
(2,0,9,'RT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hg_withdrawal_order
|
||||
(id,tenant_id,member_id,withdrawal_no,channel_id,created_at,updated_at,reviewed_at,processed_at,completed_at)
|
||||
VALUES(1,0,9,'WT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'),
|
||||
(2,0,9,'WT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recharges, err := s.RechargeOrders(ctx, 0, 9, 0, 0, 50)
|
||||
if err != nil || recharges == nil || len(recharges.Items) != 2 {
|
||||
t.Fatalf("recharges = %+v, err = %v", recharges, err)
|
||||
}
|
||||
withdrawals, err := s.WithdrawalOrders(ctx, 0, 9, 0, 0, 50)
|
||||
if err != nil || withdrawals == nil || len(withdrawals.Items) != 2 {
|
||||
t.Fatalf("withdrawals = %+v, err = %v", withdrawals, err)
|
||||
}
|
||||
for _, item := range recharges.Items {
|
||||
if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) {
|
||||
t.Errorf("recharge list time = %+v", item)
|
||||
}
|
||||
}
|
||||
for _, item := range withdrawals.Items {
|
||||
if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) {
|
||||
t.Errorf("withdrawal list time = %+v", item)
|
||||
}
|
||||
}
|
||||
recharge, err := s.RechargeOrder(ctx, 0, 9, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recharge.PaidAt != "2026-10-22T16:01:00Z" || recharge.ReviewedAt != recharge.PaidAt || recharge.CreditedAt != recharge.PaidAt {
|
||||
t.Errorf("recharge review timestamps = %+v", recharge)
|
||||
}
|
||||
withdrawal, err := s.WithdrawalOrder(ctx, 0, 9, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if withdrawal.ReviewedAt != "2026-10-22T16:01:00Z" || withdrawal.ProcessedAt != withdrawal.ReviewedAt || withdrawal.CompletedAt != withdrawal.ReviewedAt {
|
||||
t.Errorf("withdrawal review timestamps = %+v", withdrawal)
|
||||
}
|
||||
recharge, err = s.RechargeOrder(ctx, 0, 9, 2)
|
||||
if err != nil || recharge == nil || recharge.PaidAt != "" || recharge.ReviewedAt != "" || recharge.CreditedAt != "" {
|
||||
t.Errorf("empty recharge review times = %+v, err = %v", recharge, err)
|
||||
}
|
||||
withdrawal, err = s.WithdrawalOrder(ctx, 0, 9, 2)
|
||||
if err != nil || withdrawal == nil || withdrawal.ReviewedAt != "" || withdrawal.ProcessedAt != "" || withdrawal.CompletedAt != "" {
|
||||
t.Errorf("empty withdrawal review times = %+v, err = %v", withdrawal, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletAndOrderLedgerTimesKeepUTCAndBeijingWriters(t *testing.T) {
|
||||
db := financeTimeTestDB(t)
|
||||
if _, err := db.Exec(`CREATE TEMPORARY TABLE hg_wallet_ledger (
|
||||
id BIGINT,tenant_id BIGINT DEFAULT 0,member_id BIGINT DEFAULT 9,ledger_no VARCHAR(64),
|
||||
biz_type VARCHAR(64) DEFAULT 'commission',biz_no VARCHAR(64) DEFAULT 'ORDER1',entry_type VARCHAR(64) DEFAULT 'available',
|
||||
direction INT DEFAULT 1,amount BIGINT DEFAULT 100,before_amount BIGINT DEFAULT 1000,after_amount BIGINT DEFAULT 1100,
|
||||
currency VARCHAR(8) DEFAULT 'CNY',remark VARCHAR(255) DEFAULT '',idempotency_key VARCHAR(128),occurred_at DATETIME)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures := []struct{ key, remark, occurredAt, want string }{
|
||||
{"task:1:settle:commission", "任务完成自动返佣", "2026-10-22 16:00:07", "2026-10-22T16:00:07Z"},
|
||||
{"recharge:2", "储入审核到账", "2026-10-22 23:59:07", "2026-10-22T15:59:07Z"},
|
||||
{"withdrawal:W1:available", "申请取出冻结", "2026-10-22 15:58:07", "2026-10-22T15:58:07Z"},
|
||||
{"withdraw:1:consume", "取出审核", "2026-10-22 23:57:07", "2026-10-22T15:57:07Z"},
|
||||
{"task:2:admin-match:commission", "后台匹配订单完成返佣", "2026-10-22 23:56:07", "2026-10-22T15:56:07Z"},
|
||||
{"task:3:match:available", "自动派单扣除可用余额", "2026-10-22 15:55:07", "2026-10-22T15:55:07Z"},
|
||||
{"task:4:match:available", "手动订单强制付款扣除可用余额", "2026-10-22 23:54:07", "2026-10-22T15:54:07Z"},
|
||||
{"task:5:match:frozen", "会员确认手动订单冻结本金", "2026-10-22 15:53:07", "2026-10-22T15:53:07Z"},
|
||||
{"task:6:expire:available", "订单超时退回订单本金", "2026-10-22 15:52:07", "2026-10-22T15:52:07Z"},
|
||||
}
|
||||
for i, fixture := range fixtures {
|
||||
if _, err := db.Exec(`INSERT INTO hg_wallet_ledger (id,ledger_no,idempotency_key,remark,occurred_at) VALUES(?,?,?,?,?)`,
|
||||
i+1, fixture.key, fixture.key, fixture.remark, fixture.occurredAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, zone := range []string{"+00:00", "+08:00"} {
|
||||
t.Run(zone, func(t *testing.T) {
|
||||
if _, err := db.Exec(`SET SESSION time_zone=?`, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
page, err := s.WalletLedgers(context.Background(), 0, 9, "", 0, 0, 50)
|
||||
if err != nil || page == nil || len(page.Items) != len(fixtures) {
|
||||
t.Fatalf("ledger page = %+v, err = %v", page, err)
|
||||
}
|
||||
for i, fixture := range fixtures {
|
||||
if page.Items[i].LedgerNo != fixture.key || page.Items[i].OccurredAt != fixture.want {
|
||||
t.Errorf("ledger %d = %+v, want %s at %s", i, page.Items[i], fixture.key, fixture.want)
|
||||
}
|
||||
}
|
||||
rows, err := s.OrderLedgers(context.Background(), 0, 9, []string{"ORDER1"})
|
||||
if err != nil || len(rows) != len(fixtures) {
|
||||
t.Fatalf("order ledgers = %+v, err = %v", rows, err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
fixture := fixtures[len(fixtures)-i-1]
|
||||
if row["ledger_no"] != fixture.key || row["occurred_at"] != fixture.want {
|
||||
t.Errorf("order ledger = %+v, want %s at %s", row, fixture.key, fixture.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+34
-17
@@ -5,10 +5,21 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const publishStatusPublished = 3
|
||||
|
||||
var noticeBusinessLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
func noticeBusinessNow() string {
|
||||
return formatNoticeBusinessTime(time.Now())
|
||||
}
|
||||
|
||||
func formatNoticeBusinessTime(value time.Time) string {
|
||||
return value.In(noticeBusinessLocation).Format("2006-01-02 15:04:05.999999999")
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `db:"id"`
|
||||
ImageURL string `db:"image_url"`
|
||||
@@ -154,29 +165,33 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberNotice, 0)
|
||||
now := noticeBusinessNow()
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit)
|
||||
AND start_at<=? AND end_at>?
|
||||
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, now, now, limit)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
func (s *Store) NextUnreadMemberNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberNotice
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice
|
||||
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
|
||||
now := noticeBusinessNow()
|
||||
err := s.db.GetContext(ctx, &row, `SELECT n.id,n.title,n.content,n.source_type,
|
||||
DATE_FORMAT(n.start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(n.end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(n.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.member_id=? AND n.notice_status=1
|
||||
AND n.start_at<=? AND n.end_at>?
|
||||
AND NOT EXISTS (SELECT 1 FROM hg_app_member_notice_read r
|
||||
WHERE r.tenant_id=n.tenant_id AND r.notice_id=n.id AND r.member_id=?)
|
||||
ORDER BY n.start_at ASC,n.id ASC LIMIT 1`, tenantID, memberID, now, now, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -187,13 +202,14 @@ func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, no
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("store is unavailable")
|
||||
}
|
||||
now := noticeBusinessNow()
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
||||
(tenant_id,notice_id,member_id,read_at,created_at)
|
||||
SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
|
||||
SELECT n.tenant_id,n.id,?,?,?
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
|
||||
AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
||||
AND n.start_at<=? AND n.end_at>?
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, now, now, tenantID, noticeID, memberID, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,8 +228,9 @@ func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, off
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)`
|
||||
args := []any{tenantID, memberID}
|
||||
AND start_at<=? AND end_at>?`
|
||||
now := noticeBusinessNow()
|
||||
args := []any{tenantID, memberID, now, now}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
|
||||
return nil, err
|
||||
@@ -256,7 +273,7 @@ func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int)
|
||||
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
|
||||
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
|
||||
ORDER BY claimed_points DESC,m.id ASC LIMIT ?`, tenantID, limit)
|
||||
return rows, err
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func TestPendingNoticeAudienceAndReadAcknowledgement(t *testing.T) {
|
||||
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set OLONS_TEST_MYSQL_DSN to run the notice integration test")
|
||||
}
|
||||
db, err := sqlx.Connect("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
// Connection-local temporary tables shadow the real tables without changing application data.
|
||||
for _, statement := range []string{
|
||||
`CREATE TEMPORARY TABLE hg_app_member_notice (
|
||||
id BIGINT PRIMARY KEY, tenant_id BIGINT, member_id BIGINT,
|
||||
title VARCHAR(120), content TEXT, source_type INT, notice_status INT,
|
||||
start_at DATETIME(3), end_at DATETIME(3), created_at DATETIME(3))`,
|
||||
`CREATE TEMPORARY TABLE hg_app_member_notice_read (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT, notice_id BIGINT,
|
||||
member_id BIGINT, read_at DATETIME(3), created_at DATETIME(3),
|
||||
UNIQUE KEY (tenant_id, notice_id, member_id))`,
|
||||
} {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, notice := range []struct {
|
||||
id, tenantID, memberID int64
|
||||
status int
|
||||
start, end time.Time
|
||||
}{
|
||||
{1, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(time.Hour)},
|
||||
{2, 0, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{3, 0, 10, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{4, 1, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{5, 0, 0, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{6, 0, 9, 2, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{7, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(-time.Hour)},
|
||||
{8, 0, 9, 1, now.Add(time.Hour), now.Add(2 * time.Hour)},
|
||||
} {
|
||||
_, err := db.Exec(`INSERT INTO hg_app_member_notice
|
||||
(id,tenant_id,member_id,title,content,source_type,notice_status,start_at,end_at,created_at)
|
||||
VALUES (?,?,?,'系统通知','通知内容',1,?,?,?,?)`, notice.id, notice.tenantID, notice.memberID,
|
||||
notice.status, formatNoticeBusinessTime(notice.start), formatNoticeBusinessTime(notice.end), formatNoticeBusinessTime(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
s := &Store{db: db}
|
||||
for _, expectedID := range []int64{1, 2} {
|
||||
notice, err := s.NextUnreadMemberNotice(ctx, 0, 9)
|
||||
if err != nil || notice == nil || notice.ID != expectedID {
|
||||
t.Fatalf("pending notice = %+v, error = %v, want id %d", notice, err, expectedID)
|
||||
}
|
||||
if err := s.MarkMemberNoticeRead(ctx, 0, 9, expectedID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 9); err != nil || notice != nil {
|
||||
t.Fatalf("read, expired, revoked, foreign and broadcast notices must not pop up: %+v, %v", notice, err)
|
||||
}
|
||||
for _, forbiddenID := range []int64{3, 4} {
|
||||
if err := s.MarkMemberNoticeRead(ctx, 0, 9, forbiddenID); !errors.Is(err, ErrResourceNotFound) {
|
||||
t.Fatalf("reading foreign notice %d: error = %v", forbiddenID, err)
|
||||
}
|
||||
}
|
||||
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 10); err != nil || notice == nil || notice.ID != 3 {
|
||||
t.Fatalf("other recipient must retain their unread notice: %+v, %v", notice, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatNoticeBusinessTimeUsesShanghaiWallClock(t *testing.T) {
|
||||
value := time.Date(2026, 10, 22, 6, 12, 38, 0, time.UTC)
|
||||
if got, want := formatNoticeBusinessTime(value), "2026-10-22 14:12:38"; got != want {
|
||||
t.Fatalf("formatNoticeBusinessTime() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -160,10 +160,11 @@ func rechargeSelectSQL() string {
|
||||
return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount,
|
||||
o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids,
|
||||
o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no,
|
||||
COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at,
|
||||
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
COALESCE(` + beijingWallTimeUTC("o.paid_at") + `,'') AS paid_at,
|
||||
COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at,
|
||||
COALESCE(` + beijingWallTimeUTC("o.credited_at") + `,'') AS credited_at,
|
||||
` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
|
||||
CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + `
|
||||
ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at
|
||||
FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
|
||||
}
|
||||
|
||||
@@ -37,13 +37,14 @@ func (s *Store) CustomerServiceByID(ctx context.Context, tenantID, serviceID int
|
||||
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 {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
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
|
||||
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) {
|
||||
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)`,
|
||||
identity.TenantID, identity.MemberID, identity.IMTenantCode, identity.IMUserID, identity.IMUsername, identity.IMPasswordCipher)
|
||||
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.
|
||||
existing, findErr := s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
|
||||
existing, findErr := s.MemberIMIdentityForTenant(ctx, identity.TenantID, identity.MemberID, identity.IMTenantCode)
|
||||
if findErr == nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
@@ -541,7 +541,7 @@ func orderSelectSQL() string {
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
|
||||
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
|
||||
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
|
||||
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
|
||||
@@ -815,7 +815,7 @@ func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, busi
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
|
||||
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger
|
||||
`+walletLedgerTimeUTC()+` AS occurred_at FROM hg_wallet_ledger
|
||||
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package store
|
||||
|
||||
// databaseWallTimeUTC normalizes DATETIME values written by CURRENT_TIMESTAMP.
|
||||
// The database session can use UTC or Beijing time independently of the Go process.
|
||||
// Do not use it for fields explicitly written as UTC by application code.
|
||||
func databaseWallTimeUTC(column string) string {
|
||||
return `DATE_FORMAT(TIMESTAMPADD(SECOND,TIMESTAMPDIFF(SECOND,NOW(),UTC_TIMESTAMP()),` + column + `),'%Y-%m-%dT%H:%i:%sZ')`
|
||||
}
|
||||
|
||||
// beijingWallTimeUTC handles DATETIME fields explicitly written by the admin
|
||||
// service, whose business clock is Asia/Shanghai, independently of the DB session.
|
||||
func beijingWallTimeUTC(column string) string {
|
||||
return `DATE_FORMAT(TIMESTAMPADD(HOUR,-8,` + column + `),'%Y-%m-%dT%H:%i:%sZ')`
|
||||
}
|
||||
|
||||
// walletLedgerTimeUTC preserves the UTC clock used by insertTaskLedger and
|
||||
// insertWithdrawalLedger; all other ledger writers use the admin's Beijing clock.
|
||||
// Legacy forced-payment entries share the task:...:match key with client entries,
|
||||
// so their writer-provided remarks must also distinguish those two sources.
|
||||
func walletLedgerTimeUTC() string {
|
||||
return `CASE WHEN idempotency_key LIKE 'withdrawal:%'
|
||||
OR idempotency_key REGEXP '^task:[0-9]+:(settle|expire):'
|
||||
OR (idempotency_key REGEXP '^task:[0-9]+:match:(available|frozen)$' AND remark IN (
|
||||
'自动派单扣除可用余额','自动派单冻结订单本金',
|
||||
'会员确认手动订单扣除可用余额','会员确认手动订单冻结本金'))
|
||||
THEN DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ')
|
||||
ELSE ` + beijingWallTimeUTC("occurred_at") + ` END`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func TestOrderCreatedTimeRespectsDatabaseSessionZone(t *testing.T) {
|
||||
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set OLONS_TEST_MYSQL_DSN to run the time integration test")
|
||||
}
|
||||
db, err := sqlx.Connect("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if _, err := db.Exec(`CREATE TEMPORARY TABLE order_time_fixture (created_at DATETIME(3))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct{ zone, wallTime string }{
|
||||
{"+00:00", "2026-10-22 13:06:00"},
|
||||
{"+08:00", "2026-10-22 21:06:00"},
|
||||
} {
|
||||
t.Run(test.zone, func(t *testing.T) {
|
||||
if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM order_time_fixture`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO order_time_fixture (created_at) VALUES (?)`, test.wallTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var actual string
|
||||
if err := db.Get(&actual, `SELECT `+databaseWallTimeUTC("created_at")+` FROM order_time_fixture`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual != "2026-10-22T13:06:00Z" {
|
||||
t.Fatalf("normalized timestamp = %q, want 2026-10-22T13:06:00Z", actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func (s *Store) WalletLedgers(ctx context.Context, tenantID, memberID int64, biz
|
||||
items := make([]WalletLedger, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,
|
||||
amount,before_amount,after_amount,currency,remark,
|
||||
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at
|
||||
`+walletLedgerTimeUTC()+` AS occurred_at
|
||||
FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...)
|
||||
return &WalletLedgerPage{Items: items, Total: total}, err
|
||||
}
|
||||
|
||||
@@ -581,11 +581,12 @@ func withdrawalSelectSQL() string {
|
||||
o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask,
|
||||
COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code,
|
||||
o.review_reason,o.external_no,
|
||||
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at,
|
||||
COALESCE(` + beijingWallTimeUTC("o.processed_at") + `,'') AS processed_at,
|
||||
COALESCE(` + beijingWallTimeUTC("o.completed_at") + `,'') AS completed_at,
|
||||
` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
|
||||
CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + `
|
||||
ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at
|
||||
FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
|
||||
}
|
||||
|
||||
|
||||
+28
-26
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
type Repository interface {
|
||||
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)
|
||||
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)
|
||||
ResetPassword(context.Context, string, string) 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)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ type Session struct {
|
||||
Agent Agent `json:"agent"`
|
||||
}
|
||||
|
||||
const (
|
||||
publicIMAPIPath = "/v1/im"
|
||||
publicIMWebSocketPath = "/v0/channels"
|
||||
publicIMFilePath = "/v0/file"
|
||||
)
|
||||
|
||||
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
|
||||
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
|
||||
}
|
||||
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||
relationship := ""
|
||||
if err != nil {
|
||||
if !imclient.IsPermissionDenied(err) {
|
||||
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
||||
}
|
||||
relationship := strings.TrimSpace(profile.Relationship)
|
||||
relationship = "unknown"
|
||||
} else {
|
||||
relationship = strings.TrimSpace(profile.Relationship)
|
||||
}
|
||||
switch relationship {
|
||||
case "friend":
|
||||
case "none", "":
|
||||
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID)
|
||||
request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key)
|
||||
if requestErr != nil && !imclient.IsConflict(requestErr) {
|
||||
return nil, fmt.Errorf("create customer service friend request: %w", requestErr)
|
||||
case "none", "", "pending_inbound", "pending_outbound", "pending", "unknown":
|
||||
if _, err = s.im.AddFriendDirect(ctx, identity.IMUserID, agent.IMUserID); err != nil {
|
||||
if imclient.IsPermissionDenied(err) || imclient.IsConflict(err) {
|
||||
return nil, ErrRelationDenied
|
||||
}
|
||||
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)
|
||||
return nil, fmt.Errorf("add customer service as friend: %w", err)
|
||||
}
|
||||
relationship = strings.TrimSpace(profile.Relationship)
|
||||
} else {
|
||||
relationship = strings.TrimSpace(request.Status)
|
||||
}
|
||||
if relationship != "accepted" && relationship != "friend" {
|
||||
return nil, ErrRelationPending
|
||||
}
|
||||
case "pending_inbound", "pending_outbound", "pending":
|
||||
return nil, ErrRelationPending
|
||||
default:
|
||||
return nil, ErrRelationDenied
|
||||
}
|
||||
@@ -158,8 +156,8 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID
|
||||
return nil, ErrRelationDenied
|
||||
}
|
||||
return &Session{
|
||||
TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL,
|
||||
FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
||||
TenantCode: cfg.TenantCode, APIBaseURL: publicIMAPIPath, WebSocketURL: publicIMWebSocketPath,
|
||||
FileURL: publicIMFilePath, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
||||
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
|
||||
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
|
||||
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) {
|
||||
identity, err := s.repository.MemberIMIdentity(ctx, tenantID, memberID)
|
||||
identity, err := s.repository.MemberIMIdentityForTenant(ctx, tenantID, memberID, imTenantCode)
|
||||
if err == nil {
|
||||
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || identity.IMUserID == "" || identity.IMUsername == "" {
|
||||
if identity.IMUserID == "" || identity.IMUsername == "" {
|
||||
return nil, ErrIMUnavailable
|
||||
}
|
||||
if identity.IMPasswordCipher != "" {
|
||||
@@ -180,6 +178,10 @@ func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64,
|
||||
if !errors.Is(err, store.ErrResourceNotFound) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -214,7 +216,7 @@ func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64,
|
||||
if err != nil {
|
||||
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}
|
||||
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,26 +15,42 @@ import (
|
||||
type repositoryStub struct {
|
||||
agent *store.CustomerService
|
||||
identity *store.MemberIMIdentity
|
||||
profile *store.MemberIMProfile
|
||||
requestedIMTenantCode string
|
||||
}
|
||||
|
||||
func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) {
|
||||
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
|
||||
}
|
||||
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
|
||||
if r.profile == nil {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
func (r *repositoryStub) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||
return r.profile, nil
|
||||
}
|
||||
func (r *repositoryStub) SaveMemberIMIdentity(_ context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||
copy := *identity
|
||||
r.identity = ©
|
||||
return r.identity, nil
|
||||
}
|
||||
|
||||
type imStub struct {
|
||||
friendRequests int
|
||||
directFriendAdds int
|
||||
loginAttempts int
|
||||
resetPasswords int
|
||||
loginError error
|
||||
profileError error
|
||||
friendError error
|
||||
conversation *imclient.Conversation
|
||||
conversationErr error
|
||||
ensureP2PCalls int
|
||||
}
|
||||
|
||||
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",
|
||||
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) {
|
||||
return nil, nil
|
||||
func (i *imStub) CreateUser(_ context.Context, username, nickname, _ string) (*imclient.CreatedUser, error) {
|
||||
return &imclient.CreatedUser{UserID: imclient.FlexibleID("1001"), Username: username, Nickname: nickname}, nil
|
||||
}
|
||||
func (i *imStub) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) {
|
||||
return nil, nil
|
||||
@@ -61,13 +78,26 @@ func (i *imStub) ResetPassword(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) {
|
||||
i.friendRequests++
|
||||
return &imclient.FriendRequest{RequestID: "frq_1", Status: "accepted"}, nil
|
||||
func (i *imStub) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) {
|
||||
i.directFriendAdds++
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,8 +116,48 @@ func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) {
|
||||
if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" {
|
||||
t.Fatalf("unexpected session: %+v", session)
|
||||
}
|
||||
if im.friendRequests != 1 {
|
||||
t.Fatalf("friend requests = %d, want 1", im.friendRequests)
|
||||
if im.directFriendAdds != 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,9 +438,14 @@ func allowedActions(row store.TaskOrder) []string {
|
||||
switch row.OrderStatus {
|
||||
case 2:
|
||||
var snapshot map[string]any
|
||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
|
||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
|
||||
if snapshot["balance_shortage_pending"] == true {
|
||||
return []string{"contact_support"}
|
||||
}
|
||||
if snapshot["dispatch_mode"] == "manual" {
|
||||
return []string{"submit", "contact_support"}
|
||||
}
|
||||
}
|
||||
return []string{"contact_support"}
|
||||
case 3, 5, 6, 8:
|
||||
return []string{"contact_support"}
|
||||
|
||||
@@ -170,3 +170,12 @@ func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedActionsBalancePendingOrderCannotBeSubmitted(t *testing.T) {
|
||||
row := store.TaskOrder{OrderStatus: 2, RuleSnapshot: json.RawMessage(`{"dispatch_mode":"manual","balance_shortage_pending":true}`)}
|
||||
for _, action := range allowedActions(row) {
|
||||
if action == "submit" {
|
||||
t.Fatal("余额不足待处理订单不应允许提交")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,41 @@ class DeployWorkflowContractTest(unittest.TestCase):
|
||||
self.assertIn("git.olns.shop/pro/olons_server", self.workflow)
|
||||
self.assertIn("docker push", 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):
|
||||
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
|
||||
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
|
||||
self.assertIn("StrictHostKeyChecking=yes", 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("pull olons-server", self.workflow)
|
||||
self.assertIn("up -d --no-deps --wait olons-server", self.workflow)
|
||||
|
||||
def test_removes_old_kubernetes_and_registry_targets(self):
|
||||
self.assertNotIn("kubectl", self.workflow)
|
||||
self.assertNotIn("KUBECONFIG", 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__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ class DeploymentContractTest(unittest.TestCase):
|
||||
def test_workflow_targets_only_olons_server(self):
|
||||
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
|
||||
|
||||
self.assertIn("Deploy olons-server (k3s test cluster)", workflow)
|
||||
self.assertIn("git.118im.com/im_team/olons-server:", workflow)
|
||||
self.assertIn("deployment/olons-server -n olons-server-system", workflow)
|
||||
self.assertIn("Deploy olons-server production", workflow)
|
||||
self.assertIn("git.olns.shop/pro/olons_server", workflow)
|
||||
self.assertIn("pull olons-server", workflow)
|
||||
self.assertIn("up -d --no-deps --wait olons-server", workflow)
|
||||
self.assertNotIn("im-workbench", workflow)
|
||||
self.assertNotIn("workbench-system", workflow)
|
||||
|
||||
|
||||
+4
-2
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
@@ -20,7 +21,8 @@
|
||||
"@types/node": "^22.5.2",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
Generated
+311
-12
@@ -13,7 +13,7 @@ importers:
|
||||
version: 0.12.0
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.7.7
|
||||
version: 1.7.7(debug@4.4.3)
|
||||
naive-ui:
|
||||
specifier: ^2.44.1
|
||||
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
|
||||
@@ -39,6 +39,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^5.4.2
|
||||
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:
|
||||
|
||||
@@ -225,9 +228,6 @@ packages:
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
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':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -348,6 +348,35 @@ packages:
|
||||
vite: ^5.0.0
|
||||
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':
|
||||
resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==}
|
||||
|
||||
@@ -385,6 +414,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async-validator@4.2.5:
|
||||
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||
|
||||
@@ -397,6 +430,18 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -424,6 +469,19 @@ packages:
|
||||
date-fns@4.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -436,6 +494,9 @@ packages:
|
||||
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
|
||||
hasBin: true
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -444,9 +505,16 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
evtd@0.2.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -495,9 +563,15 @@ packages:
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -515,6 +589,9 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
naive-ui@2.45.3:
|
||||
resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -535,6 +612,13 @@ packages:
|
||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
|
||||
|
||||
@@ -570,6 +654,9 @@ packages:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -581,6 +668,12 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
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:
|
||||
resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -589,6 +682,24 @@ packages:
|
||||
tinode-sdk@0.25.4:
|
||||
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:
|
||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -612,6 +723,11 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -643,6 +759,31 @@ packages:
|
||||
terser:
|
||||
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:
|
||||
resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
|
||||
peerDependencies:
|
||||
@@ -666,6 +807,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.11
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.24.8': {}
|
||||
@@ -780,10 +926,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
optional: true
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
@@ -860,6 +1003,46 @@ snapshots:
|
||||
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)
|
||||
|
||||
'@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':
|
||||
dependencies:
|
||||
'@babel/parser': 7.25.6
|
||||
@@ -919,13 +1102,15 @@ snapshots:
|
||||
acorn@8.18.0:
|
||||
optional: true
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.7.7:
|
||||
axios@1.7.7(debug@4.4.3):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.6
|
||||
follow-redirects: 1.15.6(debug@4.4.3)
|
||||
form-data: 4.0.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -934,6 +1119,18 @@ snapshots:
|
||||
buffer-from@1.1.2:
|
||||
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:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -961,6 +1158,12 @@ snapshots:
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
@@ -970,6 +1173,8 @@ snapshots:
|
||||
prr: 1.0.1
|
||||
optional: true
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -998,9 +1203,17 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
@@ -1046,9 +1259,15 @@ snapshots:
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.11:
|
||||
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:
|
||||
dependencies:
|
||||
@@ -1065,6 +1284,8 @@ snapshots:
|
||||
mime@1.6.0:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)):
|
||||
dependencies:
|
||||
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
|
||||
@@ -1098,6 +1319,10 @@ snapshots:
|
||||
parse-node-version@1.0.1:
|
||||
optional: true
|
||||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
picocolors@1.1.0: {}
|
||||
|
||||
pify@4.0.1:
|
||||
@@ -1147,6 +1372,8 @@ snapshots:
|
||||
semver@5.7.2:
|
||||
optional: true
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -1158,6 +1385,10 @@ snapshots:
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
terser@5.31.6:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
@@ -1168,6 +1399,16 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
treemate@0.3.11: {}
|
||||
@@ -1184,6 +1425,24 @@ snapshots:
|
||||
evtd: 0.2.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):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
@@ -1195,6 +1454,41 @@ snapshots:
|
||||
less: 4.2.0
|
||||
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)):
|
||||
dependencies:
|
||||
evtd: 0.2.4
|
||||
@@ -1225,3 +1519,8 @@ snapshots:
|
||||
vdirs: 0.1.8(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)
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<main class="m-app" :class="{ 'm-app--with-nav': showNav }">
|
||||
<router-view />
|
||||
<SystemNoticeHost />
|
||||
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
|
||||
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
|
||||
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
|
||||
@@ -13,12 +14,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { lockHtmlScrollRightCompensationRef } from 'naive-ui/es/_utils';
|
||||
import { hasSession } from './api';
|
||||
import { mobileAssets } from './assets';
|
||||
import SystemNoticeHost from './components/SystemNoticeHost.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const showNav = computed(() => Boolean(route.meta.mobileNav));
|
||||
const scrollbarCompensation = lockHtmlScrollRightCompensationRef;
|
||||
const nav = [
|
||||
{
|
||||
label: '首页',
|
||||
@@ -71,3 +75,9 @@
|
||||
|
||||
watch(() => route.path, ensureAccess, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-bottom-nav {
|
||||
right: v-bind(scrollbarCompensation);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dateTime, dateTimeSeconds } from './api';
|
||||
|
||||
describe('Beijing business time display', () => {
|
||||
it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])(
|
||||
'displays the same Beijing time on a device using %s',
|
||||
(deviceTimeZone) => {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = deviceTimeZone;
|
||||
try {
|
||||
expect(dateTime('2026-10-22T13:06:00Z')).toBe('2026-10-22 21:06');
|
||||
expect(dateTime('2026-10-22T21:06:00+08:00')).toBe('2026-10-22 21:06');
|
||||
expect(dateTime('2026-10-22T16:00:00Z')).toBe('2026-10-23 00:00');
|
||||
expect(dateTimeSeconds('2026-10-22T13:06:07Z')).toBe('2026-10-22 21:06:07');
|
||||
expect(dateTimeSeconds('2026-10-22T21:06:07+08:00')).toBe('2026-10-22 21:06:07');
|
||||
expect(dateTimeSeconds('2026-10-22T16:00:07Z')).toBe('2026-10-23 00:00:07');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('uses a placeholder for missing or invalid timestamps', () => {
|
||||
expect(dateTime()).toBe('--');
|
||||
expect(dateTime('invalid-time')).toBe('--');
|
||||
expect(dateTimeSeconds()).toBe('--');
|
||||
expect(dateTimeSeconds('invalid-time')).toBe('--');
|
||||
});
|
||||
});
|
||||
+11
-1
@@ -180,16 +180,26 @@ export function money(value?: number, currency = 'CNY') {
|
||||
}
|
||||
|
||||
export function dateTime(value?: string) {
|
||||
return formatBeijingDateTime(value, false);
|
||||
}
|
||||
|
||||
export function dateTimeSeconds(value?: string) {
|
||||
return formatBeijingDateTime(value, true);
|
||||
}
|
||||
|
||||
function formatBeijingDateTime(value: string | undefined, withSeconds: boolean) {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
...(withSeconds ? { second: '2-digit' as const } : {}),
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
.format(date)
|
||||
.replaceAll('/', '-');
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,75 +1,59 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="announcement-fade">
|
||||
<div v-if="show" class="m-announcement-mask" role="presentation">
|
||||
<n-modal :show="show" :mask-closable="false" :close-on-esc="false" :z-index="4000">
|
||||
<section
|
||||
class="m-announcement-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
aria-describedby="announcement-content"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">
|
||||
{{ notice?.title || '公告通知' }}
|
||||
</h2>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">系统通知</h2>
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
<h3
|
||||
v-if="notice && notice.id > 0 && notice.title && notice.title !== '系统通知'"
|
||||
class="m-announcement-subtitle"
|
||||
>{{ notice.title }}</h3>
|
||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||
<template v-else>{{ notice?.content }}</template>
|
||||
</div>
|
||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
||||
<button type="button" :disabled="reading" @click="$emit('read')">
|
||||
{{ reading ? '正在确认…' : '我已阅读' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import announcementBoard from '../assets/announcement-popup@2x.png';
|
||||
import announcementBoard from '../assets/system-notice-popup@2x.png';
|
||||
|
||||
export interface AnnouncementNotice {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
body_html?: string;
|
||||
source_type?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
notice?: AnnouncementNotice | null;
|
||||
reading?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ read: [] }>();
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
document.body.classList.toggle('m-announcement-open', show);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-announcement-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px 16px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
.m-announcement-dialog {
|
||||
position: relative;
|
||||
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
||||
aspect-ratio: 2 / 3;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m-announcement-board {
|
||||
@@ -106,6 +90,12 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m-announcement-subtitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m-announcement-body button {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
@@ -131,20 +121,4 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-active,
|
||||
.announcement-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-from,
|
||||
.announcement-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.m-announcement-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<AnnouncementPopup
|
||||
:show="Boolean(activeSystemNotice)"
|
||||
:notice="activeSystemNotice"
|
||||
:reading="reading"
|
||||
@read="markRead"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AnnouncementPopup, { type AnnouncementNotice } from './AnnouncementPopup.vue';
|
||||
import {
|
||||
completeLoginNotice,
|
||||
errorText,
|
||||
hasSession,
|
||||
MobileApiError,
|
||||
request,
|
||||
shouldShowLoginNotice,
|
||||
} from '../api';
|
||||
import { activeSystemNotice, closeSystemNotice, openSystemNotice } from '../systemNotices';
|
||||
|
||||
const route = useRoute();
|
||||
const reading = ref(false);
|
||||
let timer: number | undefined;
|
||||
let refreshing = false;
|
||||
let disposed = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function refresh() {
|
||||
if (disposed || refreshing || activeSystemNotice.value || !hasSession() ||
|
||||
document.visibilityState === 'hidden') return;
|
||||
refreshing = true;
|
||||
let loadingLoginNotice = false;
|
||||
try {
|
||||
const pending = await request<AnnouncementNotice | null>({
|
||||
url: '/v1/notices/pending', signal: controller.signal,
|
||||
});
|
||||
if (disposed || activeSystemNotice.value) return;
|
||||
if (pending) {
|
||||
openSystemNotice(pending);
|
||||
return;
|
||||
}
|
||||
if (shouldShowLoginNotice()) {
|
||||
loadingLoginNotice = true;
|
||||
const loginNotice = await request<AnnouncementNotice | null>({
|
||||
url: '/v1/notices/popup', signal: controller.signal,
|
||||
});
|
||||
if (disposed || activeSystemNotice.value) return;
|
||||
if (loginNotice) openSystemNotice(loginNotice);
|
||||
else completeLoginNotice();
|
||||
}
|
||||
} catch (error) {
|
||||
if (loadingLoginNotice && error instanceof MobileApiError && error.status === 404) completeLoginNotice();
|
||||
// Leave the page usable and retry on the next poll or when the page becomes visible.
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
const notice = activeSystemNotice.value;
|
||||
if (!notice || reading.value) return;
|
||||
reading.value = true;
|
||||
try {
|
||||
if (notice.id > 0) {
|
||||
await request({
|
||||
url: `/v1/notices/${notice.id}/read`, method: 'POST', signal: controller.signal,
|
||||
});
|
||||
} else {
|
||||
completeLoginNotice();
|
||||
}
|
||||
closeSystemNotice();
|
||||
} catch (error) {
|
||||
if (error instanceof MobileApiError && error.status === 404) closeSystemNotice();
|
||||
else if (!disposed) window.$message.error(errorText(error));
|
||||
} finally {
|
||||
reading.value = false;
|
||||
}
|
||||
if (!disposed && !activeSystemNotice.value) void refresh();
|
||||
}
|
||||
|
||||
const resume = () => void refresh();
|
||||
watch(() => route.fullPath, resume);
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
timer = window.setInterval(resume, 15000);
|
||||
document.addEventListener('visibilitychange', resume);
|
||||
window.addEventListener('online', resume);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
if (timer) window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', resume);
|
||||
window.removeEventListener('online', resume);
|
||||
closeSystemNotice();
|
||||
});
|
||||
</script>
|
||||
@@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+169
-21
@@ -25,18 +25,57 @@ export interface ChatMessage {
|
||||
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 {
|
||||
private static readonly reconnectPageSize = 50;
|
||||
private static readonly maxReconnectPages = 100;
|
||||
private tinode: any;
|
||||
private topic: any;
|
||||
private currentUserID: string;
|
||||
private onMessages: (messages: ChatMessage[]) => void;
|
||||
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) {
|
||||
const endpoint = new URL(session.websocket_url);
|
||||
constructor(
|
||||
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.token = session.token;
|
||||
this.currentUserID = session.current_user_id;
|
||||
this.onMessages = onMessages;
|
||||
this.onConnectionState = options.onConnectionState || (() => undefined);
|
||||
this.tinode = new Tinode({
|
||||
appName: '悦选',
|
||||
host: endpoint.host,
|
||||
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
|
||||
this.installTenantPatch(session.tenant_code);
|
||||
this.topic = this.tinode.getTopic(session.topic);
|
||||
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) {
|
||||
this.intentionalDisconnect = false;
|
||||
this.token = token;
|
||||
this.onConnectionState('connecting');
|
||||
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('客服会话登录失败');
|
||||
this.acceptRotatedToken(login);
|
||||
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
|
||||
const subscribed = await this.topic.subscribe(query);
|
||||
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
|
||||
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
|
||||
this.sessionActive = true;
|
||||
this.onConnectionState('online');
|
||||
this.refreshMessages();
|
||||
}
|
||||
|
||||
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.intentionalDisconnect = true;
|
||||
this.sessionActive = false;
|
||||
this.onConnectionState('offline');
|
||||
try {
|
||||
this.topic?.leave?.(false);
|
||||
} 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) {
|
||||
const connection = this.tinode._connection;
|
||||
if (!connection || connection.__olonsTenantPatched) return;
|
||||
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
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.searchParams.set('apikey', this.session.api_key);
|
||||
uploadURL.searchParams.set('auth', 'token');
|
||||
uploadURL.searchParams.set('secret', this.session.token);
|
||||
const form = new FormData();
|
||||
form.set('id', clientMID());
|
||||
form.set('file', file, file.name);
|
||||
onProgress?.(0.1);
|
||||
let response: Response;
|
||||
try {
|
||||
// Do not attach XMLHttpRequest upload progress listeners here. For a
|
||||
// cross-origin IM endpoint they trigger a CORS preflight which the file
|
||||
// service does not expose, while multipart/form-data itself is accepted.
|
||||
response = await fetch(uploadURL.toString(), { method: 'POST', body: form });
|
||||
// Keep uploads on fetch so API and token credentials stay in headers and
|
||||
// never leak into the URL, browser history, or proxy access logs.
|
||||
response = await fetch(uploadURL.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-MeetingIM-APIKey': this.session.api_key,
|
||||
Authorization: `Token ${this.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('媒体上传失败,请检查网络后重试');
|
||||
}
|
||||
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
|
||||
|
||||
private mediaURL(ref?: string) {
|
||||
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);
|
||||
// The SDK signs relative file URLs. Also tolerate historical absolute IM
|
||||
// references by converting same-origin URLs back to a signable path.
|
||||
const signable = resolved.origin === apiBase.origin
|
||||
? `${resolved.pathname}${resolved.search}`
|
||||
: ref;
|
||||
// Historical messages may contain the old absolute IM host. Route only
|
||||
// known IM file paths through the current frontend origin; leave unrelated
|
||||
// external media untouched.
|
||||
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
|
||||
const signable = `${resolved.pathname}${resolved.search}`;
|
||||
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');
|
||||
if (media?.data) {
|
||||
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 {
|
||||
text: '',
|
||||
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 || '')),
|
||||
file_name: String(media.data.name || ''),
|
||||
} as const;
|
||||
|
||||
+135
-3
@@ -94,11 +94,12 @@ body {
|
||||
}
|
||||
.m-bottom-nav {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
margin-inline: auto;
|
||||
z-index: 20;
|
||||
width: min(100%, 750px);
|
||||
max-width: 750px;
|
||||
height: calc(76px + env(safe-area-inset-bottom));
|
||||
padding: 8px 18px env(safe-area-inset-bottom);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
@@ -524,19 +525,83 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-notice {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #6c5a45;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-notice p {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-notice-track {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.m-notice-track.is-marquee {
|
||||
display: inline-flex;
|
||||
gap: 28px;
|
||||
padding-right: 28px;
|
||||
width: max-content;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
animation: m-notice-marquee 18s linear infinite;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@keyframes m-notice-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.m-notice-track.is-marquee {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.m-section {
|
||||
margin-top: 12px;
|
||||
padding: 17px 16px;
|
||||
@@ -1021,6 +1086,31 @@ body {
|
||||
color: #ce8d3d;
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-order-balance-warning {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f2a5a5;
|
||||
border-radius: 8px;
|
||||
background: #fff0f0;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.m-order-balance-warning strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
.m-order-balance-warning--detail {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.m-order-balance-warning--detail p {
|
||||
margin: 5px 0 0;
|
||||
}
|
||||
.m-order-balance-warning--detail .m-order-recharge-link {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.m-detail {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1455,6 +1545,48 @@ body {
|
||||
padding: 9px 16px;
|
||||
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 {
|
||||
min-height: 530px;
|
||||
padding: 100px 30px 20px;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { AnnouncementNotice } from './components/AnnouncementPopup.vue';
|
||||
|
||||
export const activeSystemNotice = shallowRef<AnnouncementNotice | null>(null);
|
||||
|
||||
export function openSystemNotice(notice: AnnouncementNotice) {
|
||||
if (activeSystemNotice.value) return;
|
||||
activeSystemNotice.value = notice;
|
||||
}
|
||||
|
||||
export function closeSystemNotice() {
|
||||
activeSystemNotice.value = null;
|
||||
}
|
||||
@@ -52,15 +52,10 @@
|
||||
|
||||
<AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
|
||||
<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
|
||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||
/></label>
|
||||
<label
|
||||
><span>身份证号</span
|
||||
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
|
||||
/></label>
|
||||
<div class="m-bank-form__divider"></div>
|
||||
<label
|
||||
><span>银行卡号</span
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
||||
@@ -266,7 +261,6 @@
|
||||
id: number;
|
||||
bank_name: string;
|
||||
holder_name: string;
|
||||
identity_no: string;
|
||||
account_no: string;
|
||||
digital_rmb_code: string;
|
||||
wechat_qr_url: string;
|
||||
@@ -282,7 +276,6 @@
|
||||
const bank = reactive({
|
||||
bank_name: '',
|
||||
holder_name: '',
|
||||
identity_no: '',
|
||||
account_no: '',
|
||||
digital_rmb_code: '',
|
||||
wechat_qr_attachment_id: 0,
|
||||
@@ -344,7 +337,7 @@
|
||||
bankLoadError.value = '';
|
||||
error.value = '';
|
||||
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 });
|
||||
qr.wechat.url = '';
|
||||
qr.alipay.url = '';
|
||||
@@ -359,7 +352,6 @@
|
||||
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
|
||||
if (!account.masked) {
|
||||
bank.holder_name = account.holder_name;
|
||||
bank.identity_no = account.identity_no;
|
||||
bank.account_no = account.account_no;
|
||||
bank.digital_rmb_code = account.digital_rmb_code;
|
||||
}
|
||||
@@ -371,14 +363,11 @@
|
||||
}
|
||||
async function saveBank() {
|
||||
error.value = '';
|
||||
const identityNo = bank.identity_no.replace(/[ -]/g, '');
|
||||
const accountNo = bank.account_no.replace(/[ -]/g, '');
|
||||
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
|
||||
const validationError =
|
||||
bank.holder_name.length < 2
|
||||
? '请输入至少 2 个字符的姓名'
|
||||
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
|
||||
? '请输入正确的 15 位或 18 位身份证号'
|
||||
: !/^\d{8,32}$/.test(accountNo)
|
||||
? '请输入 8 至 32 位银行卡号'
|
||||
: bank.bank_name.length < 2
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<section class="m-home">
|
||||
<AnnouncementPopup
|
||||
:show="Boolean(popupNotice)"
|
||||
:notice="popupNotice"
|
||||
@read="markPopupRead"
|
||||
/>
|
||||
<div class="m-home-hero" :style="bannerStyle">
|
||||
<button
|
||||
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
||||
@@ -53,10 +48,25 @@
|
||||
</button>
|
||||
</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" />
|
||||
<p>{{ home.notices[0].title || home.notices[0].content }}</p>
|
||||
</div>
|
||||
<span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
|
||||
{{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
|
||||
</span>
|
||||
<p ref="noticeCopyRef" :key="displayNotice.id" class="m-notice-copy">
|
||||
<span :class="['m-notice-track', { 'is-marquee': noticeNeedsMarquee }]">
|
||||
<span ref="noticeTextRef">{{ noticeBarText }}</span>
|
||||
<span v-if="noticeNeedsMarquee" aria-hidden="true">{{ noticeBarText }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
|
||||
</button>
|
||||
|
||||
<section class="m-section m-card m-home-ranking">
|
||||
<div class="m-section-title"
|
||||
@@ -99,20 +109,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
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 AnnouncementPopup, {
|
||||
type AnnouncementNotice,
|
||||
} from '../components/AnnouncementPopup.vue';
|
||||
import type { AnnouncementNotice } from '../components/AnnouncementPopup.vue';
|
||||
import { openSystemNotice } from '../systemNotices';
|
||||
import {
|
||||
completeLoginNotice,
|
||||
errorText,
|
||||
money,
|
||||
request,
|
||||
safeImage,
|
||||
shouldShowLoginNotice,
|
||||
} from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
@@ -121,7 +128,7 @@
|
||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||
features: Record<string, boolean>;
|
||||
banners: BannerData[];
|
||||
notices: Array<{ title?: string; content?: string }>;
|
||||
notices: NoticeData[];
|
||||
task_summary: {
|
||||
today_count: number;
|
||||
today_commission_amount: number;
|
||||
@@ -135,6 +142,15 @@
|
||||
target_type: 'none' | 'internal_route' | 'external_url';
|
||||
target_value?: string;
|
||||
}
|
||||
interface NoticeData {
|
||||
id: number;
|
||||
title?: string;
|
||||
content?: string;
|
||||
source_type?: string;
|
||||
}
|
||||
interface NoticePageData {
|
||||
items: NoticeData[];
|
||||
}
|
||||
interface LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
@@ -147,14 +163,30 @@
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||
const homeAnnouncement = ref<AnnouncementNotice | null>(null);
|
||||
const activeBannerIndex = ref(0);
|
||||
const activeNoticeIndex = ref(0);
|
||||
let bannerTimer: number | undefined;
|
||||
let noticeTimer: number | undefined;
|
||||
let noticeRefreshTimer: number | undefined;
|
||||
let refreshingNotices = false;
|
||||
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 currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
|
||||
const displayNotice = computed(() => currentNotice.value || homeAnnouncement.value);
|
||||
const noticeBarText = computed(() => {
|
||||
const title = displayNotice.value?.title?.trim() || '';
|
||||
const content = displayNotice.value?.content?.trim() || '';
|
||||
if (title && content && title !== content) return `${title}:${content}`;
|
||||
return title || content || '查看最新通知';
|
||||
});
|
||||
const noticeCopyRef = ref<HTMLElement>();
|
||||
const noticeTextRef = ref<HTMLElement>();
|
||||
const noticeNeedsMarquee = ref(false);
|
||||
let noticeResizeObserver: ResizeObserver | undefined;
|
||||
const bannerStyle = computed(() => {
|
||||
const url = safeImage(currentBanner.value?.image_url);
|
||||
return {
|
||||
@@ -234,13 +266,29 @@
|
||||
}, 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;
|
||||
}, 12000);
|
||||
}
|
||||
}
|
||||
|
||||
function measureNoticeMarquee() {
|
||||
const element = noticeCopyRef.value;
|
||||
const text = noticeTextRef.value;
|
||||
noticeNeedsMarquee.value = Boolean(element && text && text.getBoundingClientRect().width > element.clientWidth + 1);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const noticeRequest = shouldShowLoginNotice()
|
||||
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
const noticeRequest = request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null);
|
||||
const [homeData, rankData, noticeData] = await Promise.all([
|
||||
request<HomeData>({ url: '/v1/home' }),
|
||||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||||
@@ -248,7 +296,7 @@
|
||||
]);
|
||||
home.value = homeData;
|
||||
leaderboard.value = rankData || [];
|
||||
popupNotice.value = noticeData;
|
||||
homeAnnouncement.value = noticeData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
@@ -256,21 +304,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
function markPopupRead() {
|
||||
if (!popupNotice.value) return;
|
||||
const noticeId = popupNotice.value.id;
|
||||
popupNotice.value = null;
|
||||
completeLoginNotice();
|
||||
if (noticeId > 0) {
|
||||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
||||
function openNotice() {
|
||||
if (currentNotice.value) {
|
||||
openSystemNotice({
|
||||
id: currentNotice.value.id,
|
||||
title: currentNotice.value.title || '会员公告',
|
||||
content: currentNotice.value.content || '',
|
||||
source_type: currentNotice.value.source_type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (homeAnnouncement.value) {
|
||||
openSystemNotice(homeAnnouncement.value);
|
||||
return;
|
||||
}
|
||||
void router.push('/app/content/notices');
|
||||
}
|
||||
async function refreshNotices() {
|
||||
if (document.visibilityState === 'hidden' || refreshingNotices) return;
|
||||
refreshingNotices = true;
|
||||
try {
|
||||
const page = await request<NoticePageData>({
|
||||
url: '/v1/notices',
|
||||
params: { page: 1, page_size: 5 },
|
||||
});
|
||||
if (home.value) {
|
||||
const nextNotices = page.items || [];
|
||||
const previousIds = home.value.notices.map((notice) => notice.id).join(',');
|
||||
const nextIds = nextNotices.map((notice) => notice.id).join(',');
|
||||
home.value.notices = nextNotices;
|
||||
if (previousIds !== nextIds) {
|
||||
activeNoticeIndex.value = 0;
|
||||
restartNoticeTimer();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the latest successfully loaded announcement visible during transient failures.
|
||||
} finally {
|
||||
refreshingNotices = false;
|
||||
}
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') void refreshNotices();
|
||||
}
|
||||
watch(noticeBarText, () => void nextTick(measureNoticeMarquee), { flush: 'post' });
|
||||
watch(noticeCopyRef, (element, previous) => {
|
||||
if (previous) noticeResizeObserver?.unobserve(previous);
|
||||
if (element) noticeResizeObserver?.observe(element);
|
||||
void nextTick(measureNoticeMarquee);
|
||||
}, { flush: 'post' });
|
||||
watch(() => home.value?.banners?.length, () => {
|
||||
activeBannerIndex.value = 0;
|
||||
restartBannerTimer();
|
||||
});
|
||||
onMounted(load);
|
||||
watch(() => home.value?.notices?.length, () => {
|
||||
activeNoticeIndex.value = 0;
|
||||
restartNoticeTimer();
|
||||
});
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
await nextTick();
|
||||
measureNoticeMarquee();
|
||||
if (noticeCopyRef.value && typeof ResizeObserver !== 'undefined') {
|
||||
noticeResizeObserver = new ResizeObserver(measureNoticeMarquee);
|
||||
noticeResizeObserver.observe(noticeCopyRef.value);
|
||||
}
|
||||
noticeRefreshTimer = window.setInterval(() => void refreshNotices(), 15000);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||
if (noticeTimer) window.clearInterval(noticeTimer);
|
||||
if (noticeRefreshTimer) window.clearInterval(noticeRefreshTimer);
|
||||
noticeResizeObserver?.disconnect();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
><strong>佣金:{{ money(item.commission_amount, item.currency) }}</strong></div
|
||||
>
|
||||
</div>
|
||||
<div v-if="isBalancePending(item)" class="m-order-balance-warning">
|
||||
<strong>余额不足 · 待处理</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</AsyncState>
|
||||
@@ -103,6 +107,14 @@
|
||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||
>
|
||||
</section>
|
||||
<section v-if="isBalancePending(detail)" class="m-order-balance-warning m-order-balance-warning--detail" role="alert">
|
||||
<div>
|
||||
<strong>余额不足,订单暂未完成</strong>
|
||||
<p>{{ detail.message }}</p>
|
||||
<p>充值审核通过后,系统会自动检查余额;余额足够时自动完成订单。</p>
|
||||
</div>
|
||||
<router-link class="m-order-recharge-link" to="/app/recharge">立即储入</router-link>
|
||||
</section>
|
||||
<section v-if="canSubmit" class="m-order-submit-panel">
|
||||
<div>
|
||||
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
||||
@@ -198,6 +210,8 @@
|
||||
const balanceInsufficient = computed(
|
||||
() => canSubmit.value && availableAmount.value < requiredAmount.value
|
||||
);
|
||||
const isBalancePending = (order: any) =>
|
||||
order?.status === 'pending' && String(order?.message || '').startsWith('余额不足:');
|
||||
async function loadList() {
|
||||
listController?.abort();
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
v-for="item in services"
|
||||
:key="item.id"
|
||||
class="m-service-card"
|
||||
@click="$router.push(`/app/support/${item.id}`)"
|
||||
@click="openService(item)"
|
||||
>
|
||||
<div class="m-service-avatar"
|
||||
><img
|
||||
@@ -35,21 +35,23 @@
|
||||
</div>
|
||||
</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">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
<p>首次连接会自动建立好友关系</p>
|
||||
<div v-if="connectionState !== 'online' || chatError" :class="['m-chat-connection', `is-${connectionState}`]" role="status" aria-live="polite">
|
||||
<span>{{ chatError || connectionStatusText }}</span>
|
||||
<button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-else-if="chatError" class="m-chat-empty">
|
||||
<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 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 class="m-chat-avatar" aria-hidden="true">
|
||||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
@@ -59,8 +61,11 @@
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<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 || '聊天照片'" />
|
||||
<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>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<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>
|
||||
</div>
|
||||
<small>
|
||||
@@ -70,7 +75,7 @@
|
||||
</div>
|
||||
</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-avatar m-chat-avatar--agent" aria-hidden="true">
|
||||
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
@@ -78,7 +83,7 @@
|
||||
</div>
|
||||
<div class="m-chat-system-message">
|
||||
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
|
||||
<p><b>【系统消息】</b>尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?</p>
|
||||
<p><b>【系统消息】</b>您好,我是{{ current?.name || '在线客服' }}。您可以先输入要咨询的问题,正在为您连接客服。</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="m-chat-message">
|
||||
<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 || '聊天照片'" />
|
||||
<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>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<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>
|
||||
</div>
|
||||
<small>
|
||||
@@ -103,12 +111,12 @@
|
||||
</div>
|
||||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||||
<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
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
:disabled="!chat || connectionState !== 'online' || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<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="toolsOpen && !emojiOpen" class="m-chat-tools">
|
||||
<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="mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><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="!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="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
|
||||
</div>
|
||||
@@ -145,9 +153,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
ChatbubbleEllipsesOutline,
|
||||
HappyOutline,
|
||||
HeadsetOutline,
|
||||
ImageOutline,
|
||||
@@ -157,9 +164,83 @@
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
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 router = useRouter();
|
||||
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 error = ref('');
|
||||
const services = ref<any[]>([]);
|
||||
@@ -169,8 +250,13 @@
|
||||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||||
services.value[0]
|
||||
);
|
||||
const chatLoading = ref(false);
|
||||
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 historyBoundarySeq = ref(0);
|
||||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||
@@ -179,6 +265,8 @@
|
||||
const sending = ref(false);
|
||||
const mediaSending = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const failedMediaKeys = ref(new Set<string>());
|
||||
const mediaRetries = ref<Record<string, number>>({});
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
const toolsOpen = ref(false);
|
||||
@@ -245,8 +333,8 @@
|
||||
chat = null;
|
||||
messages.value = [];
|
||||
historyBoundarySeq.value = 0;
|
||||
chatLoading.value = false;
|
||||
chatError.value = '';
|
||||
connectionState.value = 'offline';
|
||||
draft.value = '';
|
||||
sending.value = false;
|
||||
mediaSending.value = false;
|
||||
@@ -259,7 +347,9 @@
|
||||
disconnectChat();
|
||||
if (!serviceID) return;
|
||||
const version = connectionVersion;
|
||||
chatLoading.value = true;
|
||||
connectionState.value = 'connecting';
|
||||
chatError.value = '';
|
||||
sessionStartedAt.value = new Date().toISOString();
|
||||
try {
|
||||
const session = await request<CustomerServiceSession>({
|
||||
url: `/v1/customer-services/${serviceID}/session`,
|
||||
@@ -267,11 +357,19 @@
|
||||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||
});
|
||||
if (version !== connectionVersion) return;
|
||||
const nextChat = new CustomerServiceChat(session, (rows) => {
|
||||
const nextChat = new CustomerServiceChat(
|
||||
session,
|
||||
(rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
});
|
||||
},
|
||||
{
|
||||
onConnectionState: (state) => {
|
||||
if (version === connectionVersion) connectionState.value = state;
|
||||
},
|
||||
},
|
||||
);
|
||||
chat = nextChat;
|
||||
await nextChat.connect(session.token);
|
||||
if (version !== connectionVersion) {
|
||||
@@ -289,12 +387,11 @@
|
||||
chat?.disconnect();
|
||||
chat = null;
|
||||
chatError.value = errorText(err);
|
||||
} finally {
|
||||
if (version === connectionVersion) chatLoading.value = false;
|
||||
connectionState.value = 'offline';
|
||||
}
|
||||
}
|
||||
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 version = connectionVersion;
|
||||
const value = draft.value;
|
||||
@@ -318,7 +415,7 @@
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
if (!file || !chat || connectionState.value !== 'online' || mediaSending.value) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
@@ -360,6 +457,26 @@
|
||||
draft.value += emoji;
|
||||
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() {
|
||||
await nextTick();
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||
@@ -379,9 +496,62 @@
|
||||
}).format(new Date(value));
|
||||
}
|
||||
watch(() => route.params.id, (serviceID) => {
|
||||
if (useThirdPartyChat) {
|
||||
if (!serviceID) void load();
|
||||
return;
|
||||
}
|
||||
if (serviceID) void connectChat();
|
||||
else disconnectChat();
|
||||
}, { immediate: true, flush: 'sync' });
|
||||
onMounted(load);
|
||||
onBeforeUnmount(disconnectChat);
|
||||
onMounted(() => {
|
||||
if (!isChat.value) void load();
|
||||
if (useThirdPartyChat) void initializeThirdPartyChatWidget().catch(() => undefined);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disconnectChat();
|
||||
if (useThirdPartyChat && window.ChatWidget?.initialized) window.ChatWidget.close();
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
import { dateTimeSeconds, errorText, request } from '../api';
|
||||
|
||||
type Direction = 'all' | 'income' | 'expense';
|
||||
const tabs: Array<{ label: string; value: Direction }> = [
|
||||
@@ -64,22 +64,6 @@
|
||||
|
||||
const plainMoney = (value: number) => (Number(value || 0) / 100).toFixed(2);
|
||||
const entryText = (value: string) => entryLabels[value] || '账户余额变动';
|
||||
function dateTimeSeconds(value?: string) {
|
||||
if (!value) return '--';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '--';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
.format(date)
|
||||
.replaceAll('/', '-');
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
Reference in New Issue
Block a user