Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bce5edfaf5 | ||
|
|
a86db10342 | ||
|
|
70cd0e592a | ||
|
|
443622cd3c | ||
|
|
ecf1a89313 | ||
|
|
07c654b6d4 | ||
|
|
66121f48e9 | ||
|
|
6e223e537b | ||
|
|
eb96b7ac1f | ||
|
|
b6a0f819b8 | ||
|
|
05d6ec26ad | ||
|
|
acf50b9b7c | ||
|
|
06eaf748ed | ||
|
|
a6d7c8dda8 | ||
|
|
37913c37d1 | ||
|
|
48b3564ac9 | ||
|
|
f3f59fe7c3 | ||
|
|
dedacfcd9a | ||
|
|
7eedbde4ab | ||
|
|
acbfaf97a6 | ||
|
|
b3bf41af8b | ||
|
|
45ef10b2af | ||
|
|
2a3c411c9b | ||
|
|
76421fbc2d | ||
|
|
5e01e4344b | ||
|
|
4a70b85353 | ||
|
|
b2ebea4c80 |
@@ -33,5 +33,8 @@ jobs:
|
|||||||
mkdir -p ~/.kube
|
mkdir -p ~/.kube
|
||||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||||
chmod 600 ~/.kube/config
|
chmod 600 ~/.kube/config
|
||||||
|
kubectl set env deployment/olons-server \
|
||||||
|
-n olons-server-system \
|
||||||
|
OLONS_IM_INTERNAL_SECRET="${{ secrets.OLONS_IM_INTERNAL_SECRET }}"
|
||||||
kubectl rollout restart deployment/olons-server -n olons-server-system
|
kubectl rollout restart deployment/olons-server -n olons-server-system
|
||||||
kubectl rollout status deployment/olons-server -n olons-server-system --timeout=120s
|
kubectl rollout status deployment/olons-server -n olons-server-system --timeout=120s
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
storage/uploads/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
+6
-6
@@ -6,7 +6,7 @@ RUN corepack enable
|
|||||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY web/ ./
|
COPY web/ ./
|
||||||
ENV VITE_PUBLIC_PATH=/admin/
|
ENV VITE_PUBLIC_PATH=/
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# ---- Stage 2: build the Go backend ----
|
# ---- Stage 2: build the Go backend ----
|
||||||
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=web-builder /web/dist ./web/dist
|
COPY --from=web-builder /web/dist ./web/dist
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
|
||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache tzdata
|
RUN apk add --no-cache tzdata
|
||||||
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
|
||||||
COPY --from=builder /out/im_workbench /app/im_workbench
|
COPY --from=builder /out/olons_server /app/olons_server
|
||||||
COPY configs /app/configs
|
COPY configs /app/configs
|
||||||
COPY --from=web-builder /web/dist /app/web/dist
|
COPY --from=web-builder /web/dist /app/web/dist
|
||||||
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
|
ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
|
||||||
EXPOSE 6061
|
EXPOSE 6061
|
||||||
ENTRYPOINT ["/app/im_workbench"]
|
ENTRYPOINT ["/app/olons_server"]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
.PHONY: build test run web-install web-build
|
.PHONY: build test run web-install web-build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/im_workbench ./cmd/workbench
|
go build -o bin/olons_server ./cmd/olons_server
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
run:
|
run:
|
||||||
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
|
||||||
|
|
||||||
web-install:
|
web-install:
|
||||||
cd web && pnpm install --frozen-lockfile
|
cd web && pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Workbench
|
# Olons Server
|
||||||
|
|
||||||
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
||||||
|
|
||||||
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
||||||
|
|
||||||
## 当前接口
|
## 当前接口
|
||||||
|
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
- `GET /v1/manual-products/{product_id}`
|
- `GET /v1/manual-products/{product_id}`
|
||||||
- `GET /v1/payment-accounts`
|
- `GET /v1/payment-accounts`
|
||||||
- `POST /v1/payment-accounts`
|
- `POST /v1/payment-accounts`
|
||||||
|
- `PUT /v1/payment-accounts`
|
||||||
|
- `GET /v1/payment-accounts/details`
|
||||||
- `POST /v1/withdrawal-orders/quote`
|
- `POST /v1/withdrawal-orders/quote`
|
||||||
- `POST /v1/withdrawal-orders`
|
- `POST /v1/withdrawal-orders`
|
||||||
- `GET /v1/withdrawal-orders`
|
- `GET /v1/withdrawal-orders`
|
||||||
@@ -42,17 +44,16 @@
|
|||||||
核心环境变量:
|
核心环境变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
WORKBENCH_CONFIG=configs/workbench.local.json
|
OLONS_SERVER_CONFIG=configs/olons_server.local.json
|
||||||
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||||
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
|
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
|
||||||
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
|
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
|
||||||
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
|
OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
|
||||||
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
`finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
||||||
|
|
||||||
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make web-install
|
make web-install
|
||||||
@@ -60,7 +61,7 @@ make web-build
|
|||||||
make run
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
|
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
|
||||||
|
|
||||||
## 请求头
|
## 请求头
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ X-Request-ID: <request id>
|
|||||||
X-Device-ID: <optional device id>
|
X-Device-ID: <optional device id>
|
||||||
```
|
```
|
||||||
|
|
||||||
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ func main() {
|
|||||||
log.Fatal("mobile_cipher is missing before migration")
|
log.Fatal("mobile_cipher is missing before migration")
|
||||||
}
|
}
|
||||||
|
|
||||||
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||||
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||||
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
|
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/app"
|
"git.118im.com/im_team/olons_server/internal/app"
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,7 +34,7 @@ func main() {
|
|||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("workbench listening on %s", cfg.Listen)
|
log.Printf("olons_server listening on %s", cfg.Listen)
|
||||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,16 @@
|
|||||||
"addr": "127.0.0.1:6379",
|
"addr": "127.0.0.1:6379",
|
||||||
"db": 0
|
"db": 0
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"dir": "storage/uploads",
|
||||||
|
"public_path": "/uploads"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "workbench:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
"default_tenant_id": 0,
|
||||||
"data_encryption_key": "",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"lookup_hmac_key": "",
|
|
||||||
"allow_dev_header_auth": true,
|
"allow_dev_header_auth": true,
|
||||||
"required_api_key": false
|
"required_api_key": false
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,16 @@
|
|||||||
"addr": "",
|
"addr": "",
|
||||||
"db": 0
|
"db": 0
|
||||||
},
|
},
|
||||||
|
"upload": {
|
||||||
|
"dir": "storage/uploads",
|
||||||
|
"public_path": "/uploads"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "workbench:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
"default_tenant_id": 0,
|
||||||
"data_encryption_key": "",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"lookup_hmac_key": "",
|
|
||||||
"required_api_key": true
|
"required_api_key": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 718 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
# Olons Server Manual Actions Entry Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 Gitea 在 `olons_server` 仓库的 Actions 页面识别 `dev` 分支现有的手动部署工作流。
|
||||||
|
|
||||||
|
**Architecture:** 不复制或修改工作流文件,不触发部署;仅将仓库默认分支从初始化用的 `main` 改为实际开发与部署分支 `dev`。通过 Gitea API 在变更前后验证默认分支、工作流识别结果和 Actions 运行数量。
|
||||||
|
|
||||||
|
**Tech Stack:** Gitea 1.27 API、Gitea Actions、Git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 验证变更前状态
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `.gitea/workflows/deploy.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 确认远端 `dev` 包含手动部署工作流**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git show origin/dev:.gitea/workflows/deploy.yml | grep -F 'workflow_dispatch:'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 输出 `workflow_dispatch:`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行远端契约断言并确认其按预期失败**
|
||||||
|
|
||||||
|
使用现有 Gitea 登录态读取仓库与 Actions API,断言:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert repository["default_branch"] == "dev"
|
||||||
|
assert any(item["path"] == ".gitea/workflows/deploy.yml" and item["state"] == "active" for item in workflows["workflows"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL;变更前默认分支为 `main`,识别到的工作流数量为 0。
|
||||||
|
|
||||||
|
### Task 2: 修改默认分支
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: Gitea repository setting `im_team/olons_server.default_branch`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 通过 Gitea API 将默认分支改为 `dev`**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
credential_data=$(printf 'protocol=https\nhost=git.118im.com\n\n' | git credential fill)
|
||||||
|
gitea_user=$(printf '%s\n' "$credential_data" | sed -n 's/^username=//p')
|
||||||
|
gitea_password=$(printf '%s\n' "$credential_data" | sed -n 's/^password=//p')
|
||||||
|
curl -u "$gitea_user:$gitea_password" \
|
||||||
|
-X PATCH https://git.118im.com/api/v1/repos/im_team/olons_server \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"default_branch":"dev"}'
|
||||||
|
unset credential_data gitea_user gitea_password
|
||||||
|
```
|
||||||
|
|
||||||
|
认证信息仅从本机现有 Git credential helper 注入进程,不写入文件、不输出到日志。
|
||||||
|
|
||||||
|
Expected: HTTP 200,响应中的 `default_branch` 为 `dev`。
|
||||||
|
|
||||||
|
### Task 3: 验证手动入口并记录结果
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `/Users/aa/.codex/memories/extensions/ad_hoc/notes/2026-10-13T14-34-07-olons-server-manual-actions.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重新运行远端契约断言**
|
||||||
|
|
||||||
|
Expected: PASS;默认分支为 `dev`,`.gitea/workflows/deploy.yml` 状态为 `active`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 确认没有因默认分支变更产生新的部署运行**
|
||||||
|
|
||||||
|
Run: 调用 `GET /api/v1/repos/im_team/olons_server/actions/runs?limit=5`。
|
||||||
|
|
||||||
|
Expected: 运行数量与变更前相同;本任务不触发 `workflow_dispatch`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 推送设计与计划提交**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 本地 `dev` 与 `origin/dev` 一致。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 更新记忆扩展说明**
|
||||||
|
|
||||||
|
记录默认分支变更、工作流可见性验证、未触发部署以及后续手动运行入口;不得记录凭据值。
|
||||||
@@ -32,6 +32,13 @@
|
|||||||
4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。
|
4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。
|
||||||
5. 工作流只滚动更新 `olons-server-system/olons-server`,不得操作现有 `workbench-system/im-workbench`。
|
5. 工作流只滚动更新 `olons-server-system/olons-server`,不得操作现有 `workbench-system/im-workbench`。
|
||||||
|
|
||||||
|
## 手动部署入口
|
||||||
|
|
||||||
|
- 保留 `dev` 分支现有 `.gitea/workflows/deploy.yml`,不在 `main` 复制第二份工作流。
|
||||||
|
- 将 Gitea 仓库默认分支从仅含初始化文件的 `main` 改为实际开发与部署分支 `dev`,使 Gitea Actions 识别并展示现有 `workflow_dispatch` 手动运行入口。
|
||||||
|
- 此设置变更本身不触发部署;只有用户在 Actions 页面手动运行工作流时,才会构建 `dev` 当前提交、推送测试镜像并滚动更新 `olons-server-system/olons-server`。
|
||||||
|
- 不修改 `olons_admin`;其默认分支 `main` 上已有可手动运行的部署工作流。
|
||||||
|
|
||||||
## GitOps 资源
|
## GitOps 资源
|
||||||
|
|
||||||
在 `im-deploy-config` 中新增独立应用:
|
在 `im-deploy-config` 中新增独立应用:
|
||||||
|
|||||||
+4
-4
@@ -31,12 +31,12 @@ Content-Type: application/json
|
|||||||
"username": "alice01",
|
"username": "alice01",
|
||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"password": "Secret123",
|
"password": "Secret123",
|
||||||
"referral_code": "ABCD2345",
|
"referral_code": "123456",
|
||||||
"privacy_accepted": true
|
"privacy_accepted": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
|
`referral_code` 必填,必须是 6 位数字,且匹配当前租户下正常会员的 `invite_code`。邀请链接可通过 `inviteCode`、`invite_code` 或 `referral_code` 参数预填注册表单。
|
||||||
|
|
||||||
## 4 登录
|
## 4 登录
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ Content-Type: application/json
|
|||||||
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
||||||
"username": "alice01",
|
"username": "alice01",
|
||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"invite_code": "EFGH6789",
|
"invite_code": "654321",
|
||||||
"token": "...",
|
"token": "...",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_at": "2026-10-23T10:00:00Z"
|
"expires_at": "2026-10-23T10:00:00Z"
|
||||||
@@ -92,7 +92,7 @@ Authorization: Bearer <token>
|
|||||||
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
|
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
|
||||||
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
|
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
|
||||||
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
|
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
|
||||||
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
|
| `REFERRAL_CODE_INVALID` | 400 | 邀请码为空、格式错误或不存在 |
|
||||||
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
|
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
|
||||||
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
|
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
|
||||||
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
|
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
|
||||||
|
|||||||
+37
-37
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
> 版本:V1.0
|
> 版本:V1.0
|
||||||
> 更新日期:2026-10-09
|
> 更新日期:2026-10-09
|
||||||
> 适用项目:`im_workbench`
|
> 适用项目:`olons_server`
|
||||||
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
|
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
|
||||||
|
|
||||||
## 1. 文档说明
|
## 1. 文档说明
|
||||||
|
|
||||||
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
||||||
|
|
||||||
接口分为两类:
|
接口分为两类:
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ Content-Type: application/json
|
|||||||
"username": "alice01",
|
"username": "alice01",
|
||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"password": "Secret123",
|
"password": "Secret123",
|
||||||
"referral_code": "ABCD2345",
|
"referral_code": "123456",
|
||||||
"privacy_accepted": true
|
"privacy_accepted": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -177,7 +177,7 @@ Content-Type: application/json
|
|||||||
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
|
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
|
||||||
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
|
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
|
||||||
| `password` | string | 是 | 6–72 字节,不做 MD5 |
|
| `password` | string | 是 | 6–72 字节,不做 MD5 |
|
||||||
| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 |
|
| `referral_code` | string | 是 | 6 位数字,必须匹配正常会员邀请码 |
|
||||||
| `privacy_accepted` | boolean | 是 | 必须为 `true` |
|
| `privacy_accepted` | boolean | 是 | 必须为 `true` |
|
||||||
|
|
||||||
注册事务同时写入:
|
注册事务同时写入:
|
||||||
@@ -196,7 +196,7 @@ Content-Type: application/json
|
|||||||
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
||||||
"username": "alice01",
|
"username": "alice01",
|
||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"invite_code": "EFGH6789",
|
"invite_code": "654321",
|
||||||
"token": "random-base64url-token",
|
"token": "random-base64url-token",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_at": "2026-10-23T10:00:00Z"
|
"expires_at": "2026-10-23T10:00:00Z"
|
||||||
@@ -241,7 +241,7 @@ Authorization: Bearer <token>
|
|||||||
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
|
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
|
||||||
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
|
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
|
||||||
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
|
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
|
||||||
| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 |
|
| 400 | `REFERRAL_CODE_INVALID` | 邀请码为空、格式错误或不存在 |
|
||||||
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
|
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
|
||||||
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
|
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
|
||||||
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
|
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
|
||||||
@@ -265,7 +265,7 @@ GET /v1/me
|
|||||||
"username": "alice01",
|
"username": "alice01",
|
||||||
"phone": "13800138000",
|
"phone": "13800138000",
|
||||||
"avatar_url": "",
|
"avatar_url": "",
|
||||||
"invite_code": "EFGH6789",
|
"invite_code": "654321",
|
||||||
"level": {
|
"level": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"code": "normal",
|
"code": "normal",
|
||||||
@@ -303,7 +303,7 @@ PATCH /v1/me/avatar
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
附件必须是 `app_id=workbench`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
|
附件必须是 `app_id=olons_server`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
|
||||||
|
|
||||||
成功响应 `data`:
|
成功响应 `data`:
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
|
|||||||
|
|
||||||
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
|
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
|
||||||
|
|
||||||
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。当前版本在事务内直接选中可用任务,创建 `status=pending` 的订单并记录 `matched_at`,客户端收到成功响应后即可进入订单详情并提交凭证;不依赖额外匹配进程。领取不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`,任务名称和封面取自 `hg_task`。
|
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、会员等级每日领取次数和未完成订单。校验通过后先创建 `status=matching`、`order_type=2` 的派单中订单,此时商品、数量、金额和佣金均为空或 `0`,与后台订单列表的“派单中/匹配订单”原型一致。会员的 `auto_dispatch=1` 时,达到网站设置的 `autoOrderDelaySeconds` 延时后由系统自动匹配:从当前已发布、在有效期内、币种为 CNY,且价格同时满足会员可用余额与 `autoOrderMinAmount`/`autoOrderMaxAmount` 区间的 `hg_product` 中随机选取商品,按会员当前等级版本计算佣金,并将订单原子更新为 `status=pending`。匹配成功时在同一事务内从可用余额扣除订单本金并转入冻结余额,同时生成可用余额支出和冻结余额收入流水。订单完成时释放冻结本金、返回可用余额并另行入账佣金;订单超时时也必须释放并退回本金。`auto_dispatch=2` 或自动匹配暂时找不到商品时,订单保持“派单中”,管理员可在后台通过“匹配订单”人工指定商品,人工匹配也执行相同的本金扣除和冻结流程。自动和人工匹配共用同一状态条件,并发时只有一方能更新成功。新注册会员默认开启自动派单,管理员可在会员编辑弹窗修改派单模式。
|
||||||
|
|
||||||
| HTTP | `reason` | 说明 |
|
| HTTP | `reason` | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
|
|||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{}
|
||||||
"evidence_text": "已完成任务",
|
|
||||||
"evidence_attachment_ids": [1001, 1002]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
成功返回 `submission_no`、`status=reviewing`、`submitted_at` 和最新订单状态。重复提交不得生成多笔审核记录或佣金流水。
|
成功返回 `submission_no`、`status=settled`、`submitted_at` 和 `order_status=completed`。重复提交不得生成多笔提交记录或佣金流水。
|
||||||
|
|
||||||
仅 `pending` 订单允许提交。`evidence_text` 去除首尾空白后最长 2000 字符,附件最多 9 个,两者至少提供一项;附件必须属于当前租户和当前会员、`app_id=workbench`、状态有效,且类型为图片或 PDF。提交事务对订单加锁,超时订单自动置为 `expired`,有效提交创建唯一审核记录并把订单置为 `processing`。此步骤不生成佣金流水;审核通过并结算仍由后台流程负责。
|
正常情况下仅 `pending` 订单允许提交。前端按原型提供“立即提交”确认操作,不要求用户填写凭证。接口仍兼容可选的 `evidence_text` 和 `evidence_attachment_ids`:文字最长 2000 字符、附件最多 9 个;附件必须属于当前租户和当前会员、`app_id=olons_server`、状态有效,且类型为图片或 PDF。提交事务对订单、钱包加锁,超时订单自动置为 `expired`;有效提交创建唯一的已结算记录、佣金流水并增加可用余额,最后把订单置为 `completed`。后台审核页保留已结算记录供查询,冻结、强制处理和人工补偿只作为异常兜底。
|
||||||
|
|
||||||
订单详情返回:
|
订单详情返回:
|
||||||
|
|
||||||
@@ -659,7 +656,7 @@ POST /v1/task-orders/{order_id}/submissions
|
|||||||
- `submission`:凭证、审核状态与驳回原因;
|
- `submission`:凭证、审核状态与驳回原因;
|
||||||
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
|
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
|
||||||
- `ledgers`:以订单号或提交号关联的钱包流水;
|
- `ledgers`:以订单号或提交号关联的钱包流水;
|
||||||
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。
|
- `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
|
||||||
|
|
||||||
### 7.6 任务统计
|
### 7.6 任务统计
|
||||||
|
|
||||||
@@ -857,7 +854,7 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
|
|||||||
|
|
||||||
响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
|
响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
|
||||||
|
|
||||||
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
|
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。当前人工审核流程创建后直接进入 `reviewing`;`pending_payment` 仅为后续接入真实支付通道和支付回调时保留。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
|
||||||
|
|
||||||
## 11. 收款账户和取出(已实现,沙箱)
|
## 11. 收款账户和取出(已实现,沙箱)
|
||||||
|
|
||||||
@@ -870,36 +867,38 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
|
|||||||
```http
|
```http
|
||||||
GET /v1/payment-accounts
|
GET /v1/payment-accounts
|
||||||
POST /v1/payment-accounts
|
POST /v1/payment-accounts
|
||||||
|
PUT /v1/payment-accounts
|
||||||
|
GET /v1/payment-accounts/details
|
||||||
|
POST /v1/attachments/images
|
||||||
```
|
```
|
||||||
|
|
||||||
新增请求:
|
先分别上传微信和支付宝二维码。图片接口使用 `multipart/form-data`,字段名为 `file`,仅接受不超过 5MB 的 JPG、PNG、WebP,返回附件 `id` 和可访问的 `url`。随后提交新增请求:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"account_type": "bank_card",
|
|
||||||
"bank_code": "ICBC",
|
|
||||||
"bank_name": "中国工商银行",
|
"bank_name": "中国工商银行",
|
||||||
"holder_name": "张三",
|
"holder_name": "张三",
|
||||||
|
"identity_no": "11010119900101123X",
|
||||||
"account_no": "6222000000001234",
|
"account_no": "6222000000001234",
|
||||||
"reserved_phone": "13800138000",
|
"wechat_qr_attachment_id": 101,
|
||||||
"qr_attachment_id": 0,
|
"alipay_qr_attachment_id": 102
|
||||||
"trade_password": "654321"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
支持的账户类型:
|
在“取出信息”页面,`GET /v1/payment-accounts/details` 返回当前会员自己的原始绑定字段及二维码附件 ID;页面将首条默认账户回填到同一表单。修改时调用 `PUT /v1/payment-accounts`,请求体沿用新增字段并增加已有账户的 `id`。更新按租户、会员和账户 ID 校验归属,原记录 ID 保持不变;未更换二维码时可沿用已绑定的有效附件 ID。历史资料无法解密时不回填脱敏字符串,用户须重新填写敏感字段。
|
||||||
|
|
||||||
| `account_type` | 必填字段 | 说明 |
|
绑卡页面按原型固定为银行卡账户,字段规则如下:
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `bank_card` | `bank_code`、`bank_name`、`holder_name`、`account_no` | 卡号只允许 8–32 位数字,空格和连字符会被移除 |
|
| `holder_name` | 是 | 2–64 个字符 |
|
||||||
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 |
|
| `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
|
||||||
| `unionpay` | `holder_name`、`account_no` | 银联账户 |
|
| `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
|
||||||
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
| `bank_name` | 是 | 2–100 个字符 |
|
||||||
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
| `wechat_qr_attachment_id` | 是 | 当前会员上传的有效图片附件 |
|
||||||
|
| `alipay_qr_attachment_id` | 是 | 当前会员上传的有效图片附件,不能与微信二维码共用同一附件 |
|
||||||
|
|
||||||
`reserved_phone` 可选,传入时必须为 11 位中国大陆手机号;`qr_attachment_id` 可选,传入时必须是当前会员在 `workbench` 应用下有效的图片附件。绑定前必须校验 6 位数字交易密码,连续错误 5 次后锁定 15 分钟。
|
姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
|
||||||
|
|
||||||
姓名、账号和手机号使用 AES-256-GCM 独立随机 nonce 加密,账号另存 HMAC-SHA256 检索哈希用于防重复。列表只返回脱敏信息,绝不返回明文或密文。新增账户初始 `verify_status=pending`,必须由后台核验为 `verified` 后才能用于取出。
|
|
||||||
|
|
||||||
列表项示例:
|
列表项示例:
|
||||||
|
|
||||||
@@ -907,13 +906,14 @@ POST /v1/payment-accounts
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"account_type": "bank_card",
|
"account_type": "bank_card",
|
||||||
"bank_code": "ICBC",
|
"bank_code": "BANK",
|
||||||
"bank_name": "中国工商银行",
|
"bank_name": "中国工商银行",
|
||||||
"holder_mask": "张*",
|
"holder_mask": "张*",
|
||||||
"mobile_mask": "138****8000",
|
"identity_no_mask": "1101**********123X",
|
||||||
"account_no_mask": "6222********1234",
|
"account_no_mask": "6222********1234",
|
||||||
"qr_attachment_id": 0,
|
"wechat_qr_attachment_id": 101,
|
||||||
"verify_status": "pending",
|
"alipay_qr_attachment_id": 102,
|
||||||
|
"verify_status": "verified",
|
||||||
"is_default": true,
|
"is_default": true,
|
||||||
"created_at": "2026-10-09T10:00:00Z",
|
"created_at": "2026-10-09T10:00:00Z",
|
||||||
"updated_at": "2026-10-09T10:00:00Z"
|
"updated_at": "2026-10-09T10:00:00Z"
|
||||||
|
|||||||
@@ -111,9 +111,9 @@
|
|||||||
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
||||||
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
|
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
|
||||||
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
|
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
|
||||||
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
|
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
|
||||||
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
||||||
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module git.118im.com/im_team/im_workbench
|
module git.118im.com/im_team/olons_server
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
|
|||||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
|
|||||||
func TestProfileMapsMemberCategory(t *testing.T) {
|
func TestProfileMapsMemberCategory(t *testing.T) {
|
||||||
repository := &profileRepository{overview: &store.MemberOverview{
|
repository := &profileRepository{overview: &store.MemberOverview{
|
||||||
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
|
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
|
||||||
Mobile: "13800138000", Currency: "CNY", WalletStatus: 1,
|
Mobile: "13800138000", Currency: "CNY", WalletStatus: 1, HasTradePassword: 1,
|
||||||
}}
|
}}
|
||||||
service := &Service{repository: repository, ready: true}
|
service := &Service{repository: repository, ready: true}
|
||||||
profile, err := service.Profile(context.Background(), 0, 7)
|
profile, err := service.Profile(context.Background(), 0, 7)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" {
|
if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" || !profile.HasTradePassword {
|
||||||
t.Fatalf("unexpected profile states: %+v", profile)
|
t.Fatalf("unexpected profile states: %+v", profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,17 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -36,9 +40,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
|
|
||||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||||
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`)
|
referralPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ type Profile struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
|
HasTradePassword bool `json:"has_trade_password"`
|
||||||
Level ProfileLevel `json:"level"`
|
Level ProfileLevel `json:"level"`
|
||||||
MemberStatus string `json:"member_status"`
|
MemberStatus string `json:"member_status"`
|
||||||
MemberCategory string `json:"member_category"`
|
MemberCategory string `json:"member_category"`
|
||||||
@@ -142,10 +146,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
if !s.ready {
|
if !s.ready {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
input.Phone = strings.TrimSpace(input.Phone)
|
input.Phone = strings.TrimSpace(input.Phone)
|
||||||
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
||||||
if !usernamePattern.MatchString(input.Username) {
|
if !validMemberName(input.Username) {
|
||||||
return nil, ErrUsernameInvalid
|
return nil, ErrUsernameInvalid
|
||||||
}
|
}
|
||||||
if !phonePattern.MatchString(input.Phone) {
|
if !phonePattern.MatchString(input.Phone) {
|
||||||
@@ -154,7 +158,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
||||||
return nil, ErrPasswordInvalid
|
return nil, ErrPasswordInvalid
|
||||||
}
|
}
|
||||||
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
|
if !referralPattern.MatchString(input.ReferralCode) {
|
||||||
return nil, ErrReferralInvalid
|
return nil, ErrReferralInvalid
|
||||||
}
|
}
|
||||||
if !input.PrivacyAccepted {
|
if !input.PrivacyAccepted {
|
||||||
@@ -169,7 +173,8 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
inviteCode, err := newPublicID("", 8)
|
for attempt := 0; attempt < 10; attempt++ {
|
||||||
|
inviteCode, err := newInviteCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -182,14 +187,40 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
ReferralCode: input.ReferralCode,
|
ReferralCode: input.ReferralCode,
|
||||||
InviteCode: inviteCode,
|
InviteCode: inviteCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if errors.Is(err, store.ErrInviteCodeExists) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if errors.Is(err, store.ErrReferralInvalid) {
|
if errors.Is(err, store.ErrReferralInvalid) {
|
||||||
return nil, ErrReferralInvalid
|
return nil, ErrReferralInvalid
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.issueSession(ctx, account, input.DeviceID)
|
return s.issueSession(ctx, account, input.DeviceID)
|
||||||
}
|
}
|
||||||
|
return nil, ErrUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInviteCode() (string, error) {
|
||||||
|
number, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%06d", number.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMemberName(value string) bool {
|
||||||
|
count := utf8.RuneCountInString(value)
|
||||||
|
if count < 1 || count > 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if !unicode.IsLetter(r) && !unicode.IsMark(r) && r != '·' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
|
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
|
||||||
if !s.ready {
|
if !s.ready {
|
||||||
@@ -238,6 +269,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
|
|||||||
return &Profile{
|
return &Profile{
|
||||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||||
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||||
|
HasTradePassword: row.HasTradePassword != 0,
|
||||||
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
|
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
|
||||||
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
|
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
|
||||||
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
|
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
|
||||||
@@ -355,7 +387,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
|||||||
|
|
||||||
func (s *Service) loginRateKey(phone string) string {
|
func (s *Service) loginRateKey(phone string) string {
|
||||||
digest := sha256.Sum256([]byte(phone))
|
digest := sha256.Sum256([]byte(phone))
|
||||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct{}
|
type fakeRepository struct{}
|
||||||
@@ -43,11 +43,13 @@ func TestRegisterValidation(t *testing.T) {
|
|||||||
input RegisterInput
|
input RegisterInput
|
||||||
want error
|
want error
|
||||||
}{
|
}{
|
||||||
{name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
|
{name: "username", input: RegisterInput{Username: "张 三", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
|
||||||
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
|
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
|
||||||
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
|
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
|
||||||
|
{name: "referral required", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
||||||
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
||||||
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted},
|
{name: "referral length", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "12345", PrivacyAccepted: true}, want: ErrReferralInvalid},
|
||||||
|
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "123456"}, want: ErrPrivacyNotAccepted},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -59,6 +61,28 @@ func TestRegisterValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewInviteCodeIsSixDigits(t *testing.T) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
code, err := newInviteCode()
|
||||||
|
if err != nil || !referralPattern.MatchString(code) {
|
||||||
|
t.Fatalf("newInviteCode() = %q, %v", code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidMemberNameSupportsChinese(t *testing.T) {
|
||||||
|
for _, name := range []string{"杨情", "欧阳娜娜", "Alicia", "阿依古丽·买买提"} {
|
||||||
|
if !validMemberName(name) {
|
||||||
|
t.Errorf("validMemberName(%q) = false, want true", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"", "张 三", "user01", "这是一个超过十个字符长度的名字"} {
|
||||||
|
if validMemberName(name) {
|
||||||
|
t.Errorf("validMemberName(%q) = true, want false", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
|
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
|
||||||
s := &Service{}
|
s := &Service{}
|
||||||
key := s.loginRateKey("13800138000")
|
key := s.loginRateKey("13800138000")
|
||||||
|
|||||||
+20
-14
@@ -8,15 +8,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/httpapi"
|
"git.118im.com/im_team/olons_server/internal/finance"
|
||||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
"git.118im.com/im_team/olons_server/internal/httpapi"
|
||||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/mall"
|
||||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
"git.118im.com/im_team/olons_server/internal/portal"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/support"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
@@ -26,6 +29,7 @@ type App struct {
|
|||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
api *httpapi.API
|
api *httpapi.API
|
||||||
frontendPath string
|
frontendPath string
|
||||||
|
upload config.UploadConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*App, error) {
|
func New(cfg *config.Config) (*App, error) {
|
||||||
@@ -49,14 +53,16 @@ func New(cfg *config.Config) (*App, error) {
|
|||||||
taskService := tasks.New(st)
|
taskService := tasks.New(st)
|
||||||
mallService := mall.New(st)
|
mallService := mall.New(st)
|
||||||
financeService := finance.New(st, redisClient, cfg.Auth)
|
financeService := finance.New(st, redisClient, cfg.Auth)
|
||||||
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService), frontendPath: cfg.FrontendDir}, nil
|
attachmentService := attachments.New(st, cfg.Upload)
|
||||||
|
supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth)
|
||||||
|
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Handler() http.Handler {
|
func (a *App) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle(strings.TrimRight(a.upload.PublicPath, "/")+"/", http.StripPrefix(strings.TrimRight(a.upload.PublicPath, "/"), http.FileServer(http.Dir(a.upload.Dir))))
|
||||||
if a.frontendDir() != "" {
|
if a.frontendDir() != "" {
|
||||||
mux.Handle("/admin", frontendHandler(a.frontendDir()))
|
mux.Handle("/", frontendHandler(a.frontendDir()))
|
||||||
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
|
|
||||||
}
|
}
|
||||||
mux.HandleFunc("/healthz", a.api.Healthz)
|
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||||
mux.Handle("/metrics", promhttp.Handler())
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
@@ -65,7 +71,7 @@ func (a *App) Handler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) frontendDir() string {
|
func (a *App) frontendDir() string {
|
||||||
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
|
if dir := strings.TrimSpace(os.Getenv("OLONS_SERVER_FRONTEND_DIR")); dir != "" {
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(a.frontendPath)
|
return strings.TrimSpace(a.frontendPath)
|
||||||
@@ -75,7 +81,7 @@ func frontendHandler(dir string) http.Handler {
|
|||||||
files := http.FileServer(http.Dir(dir))
|
files := http.FileServer(http.Dir(dir))
|
||||||
index := filepath.Join(dir, "index.html")
|
index := filepath.Join(dir, "index.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
path := r.URL.Path
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package attachments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxImageSize = 5 << 20
|
||||||
|
|
||||||
|
var ErrImageInvalid = errors.New("image attachment invalid")
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
cfg config.UploadConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, cfg config.UploadConfig) *Service {
|
||||||
|
return &Service{repository: repository, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
|
||||||
|
if s.repository == nil || memberID <= 0 {
|
||||||
|
return nil, ErrImageInvalid
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1))
|
||||||
|
if err != nil || len(data) == 0 || len(data) > maxImageSize {
|
||||||
|
return nil, ErrImageInvalid
|
||||||
|
}
|
||||||
|
mimeType := http.DetectContentType(data)
|
||||||
|
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||||
|
ext, ok := extensions[mimeType]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrImageInvalid
|
||||||
|
}
|
||||||
|
random := make([]byte, 16)
|
||||||
|
if _, err = rand.Read(random); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileName := hex.EncodeToString(random) + ext
|
||||||
|
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.cfg.Dir, fileName)
|
||||||
|
if err = os.WriteFile(path, data, 0o640); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
digest := md5.Sum(data)
|
||||||
|
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
|
||||||
|
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
|
||||||
|
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path,
|
||||||
|
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return attachment, nil
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
|
|||||||
}
|
}
|
||||||
|
|
||||||
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
||||||
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
return "olons_server:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sessionKeyFromToken(pattern, token string) (string, error) {
|
func sessionKeyFromToken(pattern, token string) (string, error) {
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import (
|
|||||||
|
|
||||||
func TestSessionKeyFromToken(t *testing.T) {
|
func TestSessionKeyFromToken(t *testing.T) {
|
||||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||||
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
|
key, err := sessionKeyFromToken("olons_server:session:token:%s", token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 {
|
if !strings.HasPrefix(key, "olons_server:session:token:") || len(key) != len("olons_server:session:token:")+64 {
|
||||||
t.Fatalf("unexpected session key %q", key)
|
t.Fatalf("unexpected session key %q", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
|
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
|
||||||
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
|
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
|
||||||
if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
if _, err := sessionKeyFromToken("olons_server:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
||||||
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-19
@@ -18,10 +18,29 @@ type Config struct {
|
|||||||
MySQL MySQLConfig `json:"mysql"`
|
MySQL MySQLConfig `json:"mysql"`
|
||||||
Redis RedisConfig `json:"redis"`
|
Redis RedisConfig `json:"redis"`
|
||||||
Auth AuthConfig `json:"auth"`
|
Auth AuthConfig `json:"auth"`
|
||||||
|
Upload UploadConfig `json:"upload"`
|
||||||
|
IM IMConfig `json:"im"`
|
||||||
ReadTimeout time.Duration `json:"-"`
|
ReadTimeout time.Duration `json:"-"`
|
||||||
WriteTimeout time.Duration `json:"-"`
|
WriteTimeout time.Duration `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IMConfig struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
HTTPAPIURL string `json:"http_api_url"`
|
||||||
|
WebSocketURL string `json:"websocket_url"`
|
||||||
|
FileURL string `json:"file_url"`
|
||||||
|
TenantCode string `json:"tenant_code"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
InternalService string `json:"internal_service"`
|
||||||
|
InternalSecret string `json:"internal_secret"`
|
||||||
|
Timeout time.Duration `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadConfig struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
PublicPath string `json:"public_path"`
|
||||||
|
}
|
||||||
|
|
||||||
type MySQLConfig struct {
|
type MySQLConfig struct {
|
||||||
DSN string `json:"dsn"`
|
DSN string `json:"dsn"`
|
||||||
MaxOpenConns int `json:"max_open_conns"`
|
MaxOpenConns int `json:"max_open_conns"`
|
||||||
@@ -42,13 +61,14 @@ type AuthConfig struct {
|
|||||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||||
DataEncryptionKey string `json:"data_encryption_key"`
|
DataEncryptionKey string `json:"data_encryption_key"`
|
||||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||||
|
FinanceMasterKey string `json:"finance_master_key"`
|
||||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||||
RequiredAPIKey bool `json:"required_api_key"`
|
RequiredAPIKey bool `json:"required_api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" {
|
if path := strings.TrimSpace(os.Getenv("OLONS_SERVER_CONFIG")); path != "" {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -75,34 +95,53 @@ func defaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SessionKeyPattern: "workbench:session:token:%s",
|
SessionKeyPattern: "olons_server:session:token:%s",
|
||||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||||
DefaultTenantID: 0,
|
DefaultTenantID: 0,
|
||||||
RequiredAPIKey: true,
|
RequiredAPIKey: true,
|
||||||
},
|
},
|
||||||
|
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
|
||||||
|
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",
|
||||||
|
InternalService: "olons-server", Timeout: 8 * time.Second,
|
||||||
|
},
|
||||||
ReadTimeout: 8 * time.Second,
|
ReadTimeout: 8 * time.Second,
|
||||||
WriteTimeout: 8 * time.Second,
|
WriteTimeout: 8 * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyEnv(cfg *Config) {
|
func applyEnv(cfg *Config) {
|
||||||
setString(&cfg.Listen, "WORKBENCH_LISTEN")
|
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
|
||||||
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
|
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
|
||||||
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
|
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
|
||||||
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
|
||||||
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
|
||||||
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
|
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
|
||||||
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
|
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
|
||||||
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
|
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
|
||||||
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
|
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||||
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
|
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||||
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
|
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||||
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
|
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
||||||
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
|
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||||
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
|
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||||
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
|
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
||||||
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
|
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||||
|
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
|
||||||
|
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
|
||||||
|
setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL")
|
||||||
|
setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL")
|
||||||
|
setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL")
|
||||||
|
setString(&cfg.IM.FileURL, "OLONS_IM_FILE_URL")
|
||||||
|
setString(&cfg.IM.TenantCode, "OLONS_IM_TENANT_CODE")
|
||||||
|
setString(&cfg.IM.APIKey, "OLONS_IM_API_KEY")
|
||||||
|
setString(&cfg.IM.InternalService, "OLONS_IM_INTERNAL_SERVICE")
|
||||||
|
setString(&cfg.IM.InternalSecret, "OLONS_IM_INTERNAL_SECRET")
|
||||||
|
setDuration(&cfg.IM.Timeout, "OLONS_IM_TIMEOUT")
|
||||||
|
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
|
||||||
cfg.Auth.APIKeys = splitCSV(keys)
|
cfg.Auth.APIKeys = splitCSV(keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +172,12 @@ func (c *Config) validate() error {
|
|||||||
if c.Auth.DefaultTenantID < 0 {
|
if c.Auth.DefaultTenantID < 0 {
|
||||||
return errors.New("default tenant id must not be negative")
|
return errors.New("default tenant id must not be negative")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
|
||||||
|
return errors.New("upload config is invalid")
|
||||||
|
}
|
||||||
|
if c.IM.Timeout <= 0 {
|
||||||
|
c.IM.Timeout = 8 * time.Second
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +212,14 @@ func setBool(target *bool, key string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDuration(target *time.Duration, key string) {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
if parsed, err := time.ParseDuration(value); err == nil {
|
||||||
|
*target = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func splitCSV(value string) []string {
|
func splitCSV(value string) []string {
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
items := make([]string, 0, len(parts))
|
items := make([]string, 0, len(parts))
|
||||||
|
|||||||
+190
-93
@@ -19,8 +19,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -34,17 +34,17 @@ const (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
|
||||||
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
|
|
||||||
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
|
|
||||||
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
||||||
|
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
||||||
PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error)
|
PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error)
|
||||||
|
PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error)
|
||||||
CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error)
|
CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error)
|
||||||
|
UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error)
|
||||||
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
|
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
|
||||||
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
|
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
|
||||||
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
|
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
|
||||||
@@ -68,14 +68,14 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PaymentAccountInput struct {
|
type PaymentAccountInput struct {
|
||||||
AccountType string `json:"account_type"`
|
ID int64 `json:"id"`
|
||||||
BankCode string `json:"bank_code"`
|
|
||||||
BankName string `json:"bank_name"`
|
BankName string `json:"bank_name"`
|
||||||
HolderName string `json:"holder_name"`
|
HolderName string `json:"holder_name"`
|
||||||
|
IdentityNo string `json:"identity_no"`
|
||||||
AccountNo string `json:"account_no"`
|
AccountNo string `json:"account_no"`
|
||||||
ReservedPhone string `json:"reserved_phone"`
|
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||||
TradePassword string `json:"trade_password"`
|
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PaymentAccount struct {
|
type PaymentAccount struct {
|
||||||
@@ -84,15 +84,33 @@ type PaymentAccount struct {
|
|||||||
BankCode string `json:"bank_code"`
|
BankCode string `json:"bank_code"`
|
||||||
BankName string `json:"bank_name"`
|
BankName string `json:"bank_name"`
|
||||||
HolderMask string `json:"holder_mask"`
|
HolderMask string `json:"holder_mask"`
|
||||||
MobileMask string `json:"mobile_mask"`
|
IdentityNoMask string `json:"identity_no_mask"`
|
||||||
AccountNoMask string `json:"account_no_mask"`
|
AccountNoMask string `json:"account_no_mask"`
|
||||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
|
||||||
|
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||||
|
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||||
VerifyStatus string `json:"verify_status"`
|
VerifyStatus string `json:"verify_status"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
AlipayQRURL string `json:"alipay_qr_url"`
|
||||||
|
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||||
|
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||||
|
VerifyStatus string `json:"verify_status"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
Masked bool `json:"masked"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuoteInput struct {
|
type QuoteInput struct {
|
||||||
PaymentAccountID int64 `json:"payment_account_id"`
|
PaymentAccountID int64 `json:"payment_account_id"`
|
||||||
Amount int64 `json:"amount"`
|
Amount int64 `json:"amount"`
|
||||||
@@ -235,11 +253,19 @@ type WalletLedgerPage struct {
|
|||||||
|
|
||||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
|
||||||
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
|
var encryptionKey, lookupKey []byte
|
||||||
|
if len([]byte(masterKey)) >= 32 {
|
||||||
|
encryptionKey = deriveKey(masterKey, "data-encryption")
|
||||||
|
lookupKey = deriveKey(masterKey, "lookup-hmac")
|
||||||
|
} else {
|
||||||
|
var encryptionErr, lookupErr error
|
||||||
|
encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey)
|
||||||
|
lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey)
|
||||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
}
|
||||||
block, err := aes.NewCipher(encryptionKey)
|
block, err := aes.NewCipher(encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return service
|
return service
|
||||||
@@ -254,6 +280,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
|||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deriveKey(masterKey, purpose string) []byte {
|
||||||
|
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||||
|
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||||
if s.repository == nil {
|
if s.repository == nil {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
@@ -269,41 +301,57 @@ func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) {
|
||||||
if !s.ready {
|
if s.repository == nil {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
accountType, err := normalizePaymentAccount(&input)
|
rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
|
items := make([]PaymentAccountDetail, 0, len(rows))
|
||||||
if err != nil {
|
for _, row := range rows {
|
||||||
return nil, err
|
holderName, accountNo := row.HolderMask, row.AccountNoMask
|
||||||
}
|
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
|
||||||
holderCipher, err := s.encrypt(input.HolderName)
|
masked := true
|
||||||
if err != nil {
|
if s.ready {
|
||||||
return nil, err
|
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
|
||||||
}
|
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
|
||||||
accountCipher, err := s.encrypt(input.AccountNo)
|
if holderErr == nil && accountErr == nil {
|
||||||
if err != nil {
|
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
|
||||||
return nil, err
|
if row.IdentityNoCipher != "" {
|
||||||
}
|
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
|
||||||
mobileCipher := ""
|
identityNo = value
|
||||||
if input.ReservedPhone != "" {
|
} else {
|
||||||
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone)
|
masked = true
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
|
if row.DigitalRMBCodeCipher != "" {
|
||||||
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
|
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
|
||||||
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
|
digitalRMBCode = value
|
||||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher,
|
} else {
|
||||||
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher,
|
masked = true
|
||||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
}
|
||||||
QRAttachmentID: input.QRAttachmentID,
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, PaymentAccountDetail{
|
||||||
|
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
||||||
|
IdentityNo: identityNo, 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,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||||
|
data, err := s.paymentAccountCreation(tenantID, memberID, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := s.repository.CreatePaymentAccount(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -311,6 +359,63 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||||
|
if input.ID <= 0 {
|
||||||
|
return nil, ErrAccountFieldsInvalid
|
||||||
|
}
|
||||||
|
data, err := s.paymentAccountCreation(tenantID, memberID, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row, err := s.repository.UpdatePaymentAccount(ctx, input.ID, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := paymentAccountFromStore(*row)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) paymentAccountCreation(tenantID, memberID int64, input PaymentAccountInput) (store.PaymentAccountCreation, error) {
|
||||||
|
if !s.ready {
|
||||||
|
return store.PaymentAccountCreation{}, ErrUnavailable
|
||||||
|
}
|
||||||
|
if err := normalizePaymentAccount(&input); err != nil {
|
||||||
|
return store.PaymentAccountCreation{}, err
|
||||||
|
}
|
||||||
|
holderCipher, err := s.encrypt(input.HolderName)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", ""
|
||||||
|
if input.DigitalRMBCode != "" {
|
||||||
|
digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode)
|
||||||
|
if err != nil {
|
||||||
|
return store.PaymentAccountCreation{}, err
|
||||||
|
}
|
||||||
|
digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode)
|
||||||
|
digitalRMBCodeMask = maskAccount(input.DigitalRMBCode)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||||
|
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
|
||||||
|
DigitalRMBCodeMask: digitalRMBCodeMask,
|
||||||
|
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||||
|
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||||
if !s.ready {
|
if !s.ready {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
@@ -349,9 +454,16 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, store.ErrPaymentAccountInvalid
|
return nil, store.ErrPaymentAccountInvalid
|
||||||
}
|
}
|
||||||
reservedPhone := ""
|
identityNo := ""
|
||||||
if account.MobileCipher != "" {
|
if account.IdentityNoCipher != "" {
|
||||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
||||||
|
if err != nil {
|
||||||
|
return nil, store.ErrPaymentAccountInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digitalRMBCode := ""
|
||||||
|
if account.DigitalRMBCodeCipher != "" {
|
||||||
|
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, store.ErrPaymentAccountInvalid
|
return nil, store.ErrPaymentAccountInvalid
|
||||||
}
|
}
|
||||||
@@ -359,8 +471,10 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
|||||||
snapshot, err := json.Marshal(map[string]any{
|
snapshot, err := json.Marshal(map[string]any{
|
||||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||||
"bank_name": account.BankName, "holder_name": holderName,
|
"bank_name": account.BankName, "holder_name": holderName,
|
||||||
"reserved_phone": reservedPhone, "account_no": accountNo,
|
"identity_no": identityNo, "account_no": accountNo,
|
||||||
"qr_attachment_id": account.QRAttachmentID,
|
"digital_rmb_code": digitalRMBCode,
|
||||||
|
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||||
|
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -375,8 +489,12 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
|||||||
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
|
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
|
||||||
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
|
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
|
||||||
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
|
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
|
||||||
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
|
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
|
||||||
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
|
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
|
||||||
|
ExpectedDigitalRMBCodeCipher: account.DigitalRMBCodeCipher,
|
||||||
|
ExpectedDigitalRMBCodeHash: account.DigitalRMBCodeHash,
|
||||||
|
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
|
||||||
|
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
|
||||||
Channel: *channel,
|
Channel: *channel,
|
||||||
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
||||||
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
||||||
@@ -695,7 +813,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
|||||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||||
return nil, ErrTradePassword
|
return nil, ErrTradePassword
|
||||||
}
|
}
|
||||||
key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
||||||
attempts, err := s.redis.Get(ctx, key).Int()
|
attempts, err := s.redis.Get(ctx, key).Int()
|
||||||
if err != nil && !errors.Is(err, redis.Nil) {
|
if err != nil && !errors.Is(err, redis.Nil) {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
@@ -725,57 +843,29 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
|||||||
return security, nil
|
return security, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
|
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||||
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
|
|
||||||
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
|
|
||||||
input.BankName = strings.TrimSpace(input.BankName)
|
input.BankName = strings.TrimSpace(input.BankName)
|
||||||
input.HolderName = strings.TrimSpace(input.HolderName)
|
input.HolderName = strings.TrimSpace(input.HolderName)
|
||||||
input.AccountNo = strings.TrimSpace(input.AccountNo)
|
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
||||||
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
|
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
||||||
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
|
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
|
||||||
return 0, ErrAccountFieldsInvalid
|
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
||||||
}
|
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
||||||
switch input.AccountType {
|
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||||
case "bank_card":
|
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
|
||||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
|
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
|
||||||
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
|
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
|
||||||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
|
return ErrAccountFieldsInvalid
|
||||||
return 0, ErrAccountFieldsInvalid
|
|
||||||
}
|
|
||||||
return 1, nil
|
|
||||||
case "digital_wallet":
|
|
||||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
|
||||||
return 0, ErrAccountFieldsInvalid
|
|
||||||
}
|
|
||||||
return 2, nil
|
|
||||||
case "unionpay":
|
|
||||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
|
||||||
return 0, ErrAccountFieldsInvalid
|
|
||||||
}
|
|
||||||
return 3, nil
|
|
||||||
case "alipay":
|
|
||||||
input.BankName = "支付宝"
|
|
||||||
input.BankCode = "ALIPAY"
|
|
||||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
|
||||||
return 0, ErrAccountFieldsInvalid
|
|
||||||
}
|
|
||||||
return 4, nil
|
|
||||||
case "wechat":
|
|
||||||
input.BankName = "微信"
|
|
||||||
input.BankCode = "WECHAT"
|
|
||||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
|
||||||
return 0, ErrAccountFieldsInvalid
|
|
||||||
}
|
|
||||||
return 5, nil
|
|
||||||
default:
|
|
||||||
return 0, ErrAccountTypeInvalid
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||||
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
||||||
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
|
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
|
||||||
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
|
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
|
||||||
|
WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||||
|
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||||
}
|
}
|
||||||
@@ -1023,6 +1113,13 @@ func maskAccount(value string) string {
|
|||||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maskIdentityNo(value string) string {
|
||||||
|
if len(value) <= 8 {
|
||||||
|
return strings.Repeat("*", len(value))
|
||||||
|
}
|
||||||
|
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
func validHumanName(value string) bool {
|
func validHumanName(value string) bool {
|
||||||
count := utf8.RuneCountInString(value)
|
count := utf8.RuneCountInString(value)
|
||||||
if count < 2 || count > 64 {
|
if count < 2 || count > 64 {
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
config json.RawMessage
|
config json.RawMessage
|
||||||
account *store.PaymentAccount
|
account *store.PaymentAccount
|
||||||
|
details []store.PaymentAccount
|
||||||
channel *store.WithdrawalChannel
|
channel *store.WithdrawalChannel
|
||||||
wallet *store.WithdrawalWallet
|
wallet *store.WithdrawalWallet
|
||||||
orders []store.WithdrawalOrder
|
orders []store.WithdrawalOrder
|
||||||
@@ -23,6 +25,24 @@ type fakeRepository struct {
|
|||||||
createdRecharge store.RechargeCreation
|
createdRecharge store.RechargeCreation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type paymentAccountRepository struct {
|
||||||
|
fakeRepository
|
||||||
|
created store.PaymentAccountCreation
|
||||||
|
updated store.PaymentAccountCreation
|
||||||
|
updatedID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
||||||
|
f.created = input
|
||||||
|
return &store.PaymentAccount{ID: 1, AccountType: input.AccountType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentAccountRepository) UpdatePaymentAccount(_ context.Context, id int64, input store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
||||||
|
f.updatedID = id
|
||||||
|
f.updated = input
|
||||||
|
return &store.PaymentAccount{ID: id, AccountType: input.AccountType, BankName: input.BankName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||||
return f.config, nil
|
return f.config, nil
|
||||||
}
|
}
|
||||||
@@ -32,9 +52,15 @@ func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.
|
|||||||
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
||||||
|
return f.details, nil
|
||||||
|
}
|
||||||
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
|
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
|
||||||
return f.account, nil
|
return f.account, nil
|
||||||
}
|
}
|
||||||
@@ -60,7 +86,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
|
|||||||
f.createdRecharge = input
|
f.createdRecharge = input
|
||||||
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
||||||
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||||
Status: 1, Remark: input.Remark}, nil
|
Status: 2, Remark: input.Remark}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
||||||
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
||||||
@@ -101,15 +127,118 @@ func TestQuoteFailsClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizePaymentAccount(t *testing.T) {
|
func TestNormalizePaymentAccount(t *testing.T) {
|
||||||
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||||
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
|
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
||||||
accountType, err := normalizePaymentAccount(&input)
|
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" {
|
||||||
|
t.Fatalf("unexpected normalized input: %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
||||||
|
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||||
|
IdentityNo: "11010119900101123X", AccountNo: "6222000000001234",
|
||||||
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||||
|
if err := normalizePaymentAccount(&input); err != nil {
|
||||||
|
t.Fatalf("empty digital RMB code should be optional: %v", err)
|
||||||
|
}
|
||||||
|
input.DigitalRMBCode = "1234"
|
||||||
|
if err := normalizePaymentAccount(&input); !errors.Is(err, ErrAccountFieldsInvalid) {
|
||||||
|
t.Fatalf("short non-empty digital RMB code should be rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
|
||||||
|
repository := &fakeRepository{}
|
||||||
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||||
|
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,
|
||||||
|
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
|
||||||
|
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
|
||||||
|
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
|
||||||
|
}}
|
||||||
|
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
|
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" ||
|
||||||
maskAccount(input.AccountNo) != "6222********1234" {
|
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
|
||||||
t.Fatalf("unexpected normalized input: %#v", input)
|
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
|
||||||
|
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
|
||||||
|
t.Fatalf("unexpected 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",
|
||||||
|
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.ID != 7 || repository.updatedID != 7 || repository.updated.TenantID != 1 || repository.updated.MemberID != 2 ||
|
||||||
|
repository.updated.WechatQRAttachmentID != 8 || repository.updated.AlipayQRAttachmentID != 9 ||
|
||||||
|
repository.updated.DigitalRMBCodeCipher != "" {
|
||||||
|
t.Fatalf("unexpected updated account: result=%#v data=%#v", result, repository.updated)
|
||||||
|
}
|
||||||
|
if holder, err := service.decrypt(repository.updated.HolderCipher); err != nil || holder != "李四" {
|
||||||
|
t.Fatalf("edited holder was not encrypted correctly: %q, %v", holder, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *testing.T) {
|
||||||
|
repository := &fakeRepository{details: []store.PaymentAccount{{
|
||||||
|
ID: 8, BankName: "中国银行", HolderCipher: "legacy-holder", HolderMask: "张*",
|
||||||
|
IdentityNoCipher: "legacy-id", IdentityNoMask: "1101**********123X",
|
||||||
|
AccountNoCipher: "legacy-account", AccountNoMask: "6222********1234",
|
||||||
|
DigitalRMBCodeCipher: "legacy-rmb", DigitalRMBCodeMask: "12****78",
|
||||||
|
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", VerifyStatus: 2,
|
||||||
|
}}}
|
||||||
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||||
|
FinanceMasterKey: "different-finance-master-key-with-32-characters",
|
||||||
|
})
|
||||||
|
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].IdentityNo != "1101**********123X" || items[0].AccountNo != "6222********1234" ||
|
||||||
|
items[0].DigitalRMBCode != "12****78" {
|
||||||
|
t.Fatalf("unexpected masked fallback: %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
|
||||||
|
repository := &paymentAccountRepository{}
|
||||||
|
service := New(repository, &redis.Client{}, config.AuthConfig{
|
||||||
|
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
|
||||||
|
})
|
||||||
|
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
|
||||||
|
BankName: "中国工商银行", HolderName: "张三", IdentityNo: "11010119900101123X",
|
||||||
|
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +267,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
|
if result.Status != "reviewing" || result.RequestAmount != 50000 ||
|
||||||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
||||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||||
}
|
}
|
||||||
@@ -219,3 +348,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
|
||||||
|
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
||||||
|
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
|
||||||
|
})
|
||||||
|
if service.aead == nil || len(service.lookupKey) != 32 {
|
||||||
|
t.Fatal("expected the finance keys to be derived from the master key")
|
||||||
|
}
|
||||||
|
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
|
||||||
|
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
|
||||||
|
t.Fatal("derived keys must be isolated by purpose")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type accountRegisterRequest struct {
|
type accountRegisterRequest struct {
|
||||||
|
|||||||
+18
-9
@@ -3,12 +3,14 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
"git.118im.com/im_team/olons_server/internal/finance"
|
||||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
"git.118im.com/im_team/olons_server/internal/mall"
|
||||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
"git.118im.com/im_team/olons_server/internal/portal"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/support"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type API struct {
|
type API struct {
|
||||||
@@ -18,10 +20,12 @@ type API struct {
|
|||||||
tasks *tasks.Service
|
tasks *tasks.Service
|
||||||
mall *mall.Service
|
mall *mall.Service
|
||||||
finance *finance.Service
|
finance *finance.Service
|
||||||
|
attachments *attachments.Service
|
||||||
|
support *support.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API {
|
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service, supportService *support.Service) *API {
|
||||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
|
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService, support: supportService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Register(mux *http.ServeMux) {
|
func (a *API) Register(mux *http.ServeMux) {
|
||||||
@@ -38,7 +42,10 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
||||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||||
|
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
||||||
mux.HandleFunc("/v1/notices", a.notices)
|
mux.HandleFunc("/v1/notices", a.notices)
|
||||||
|
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||||
|
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
||||||
@@ -48,6 +55,8 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
||||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||||
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
||||||
|
mux.HandleFunc("/v1/payment-accounts/details", a.paymentAccountDetails)
|
||||||
|
mux.HandleFunc("/v1/attachments/images", a.uploadImage)
|
||||||
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
|
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
|
||||||
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
|
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
|
||||||
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
||||||
@@ -58,5 +67,5 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
|
writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, ok := a.authenticateMember(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
result, err := a.attachments.UploadImage(r.Context(), principal.MemberID, header.Filename, file)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, attachments.ErrImageInvalid) {
|
||||||
|
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||||
|
} else {
|
||||||
|
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, result)
|
||||||
|
}
|
||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
"git.118im.com/im_team/olons_server/internal/finance"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPut {
|
||||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
methodNotAllowed(w, r, http.MethodGet, http.MethodPost, http.MethodPut)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
principal, ok := a.authenticateFinanceMember(w, r)
|
principal, ok := a.authenticateFinanceMember(w, r)
|
||||||
@@ -33,7 +33,31 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeBody(w, r, &body) {
|
if !decodeBody(w, r, &body) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
|
var result *finance.PaymentAccount
|
||||||
|
var err error
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
result, err = a.finance.UpdatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||||
|
} else {
|
||||||
|
result, err = a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeFinanceError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) paymentAccountDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
methodNotAllowed(w, r, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, ok := a.authenticateFinanceMember(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
result, err := a.finance.PaymentAccountDetails(r.Context(), principal.TenantID, principal.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeFinanceError(w, r, err)
|
writeFinanceError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
"git.118im.com/im_team/olons_server/internal/mall"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
"git.118im.com/im_team/olons_server/internal/portal"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var publicContentKeys = map[string]struct{}{
|
var publicContentKeys = map[string]struct{}{
|
||||||
"privacy_policy": {},
|
"privacy_policy": {},
|
||||||
"user_agreement": {},
|
"user_agreement": {},
|
||||||
|
"platform_intro": {},
|
||||||
|
"platform_introduction": {},
|
||||||
|
"frequently_asked_questions": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) home(w http.ResponseWriter, r *http.Request) {
|
func (a *API) home(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -187,6 +191,56 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeSuccess(w, r, result)
|
writeSuccess(w, r, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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.PopupNotice(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, "/")
|
||||||
|
if len(parts) != 2 || parts[1] != "read" {
|
||||||
|
writePortalError(w, r, store.ErrResourceNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
noticeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil || noticeID <= 0 {
|
||||||
|
writePortalError(w, r, store.ErrResourceNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.ensurePortal(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, ok := a.authenticateMember(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.portal.MarkNoticeRead(r.Context(), principal.TenantID, principal.MemberID, noticeID); err != nil {
|
||||||
|
writePortalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, struct{}{})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if a.portal != nil {
|
if a.portal != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type avatarRequest struct {
|
type avatarRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/support"
|
||||||
|
)
|
||||||
|
|
||||||
|
type customerServiceSessionRequest struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) customerServiceResource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/customer-services/"), "/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) != 2 || parts[1] != "session" {
|
||||||
|
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serviceID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil || serviceID <= 0 {
|
||||||
|
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w, r, http.MethodPost)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, ok := a.authenticateMember(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.support == nil {
|
||||||
|
writeSupportError(w, r, support.ErrIMUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body customerServiceSessionRequest
|
||||||
|
if !decodeBody(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := strings.TrimSpace(body.DeviceID)
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = principal.DeviceID
|
||||||
|
}
|
||||||
|
result, err := a.support.OpenSession(r.Context(), principal.TenantID, principal.MemberID, serviceID,
|
||||||
|
support.SessionRequest{DeviceID: deviceID, Platform: body.Platform})
|
||||||
|
if err != nil {
|
||||||
|
writeSupportError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSuccess(w, r, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSupportError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
log.Printf("customer service session failed request_id=%s err=%v", r.Header.Get("X-Request-ID"), err)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, support.ErrServiceNotFound):
|
||||||
|
writeError(w, r, http.StatusNotFound, 41001, "CUSTOMER_SERVICE_NOT_FOUND", "customer service not found", nil)
|
||||||
|
case errors.Is(err, support.ErrServiceNotBound):
|
||||||
|
writeError(w, r, http.StatusConflict, 41002, "CUSTOMER_SERVICE_NOT_BOUND", "customer service account is not configured", nil)
|
||||||
|
case errors.Is(err, support.ErrRelationPending):
|
||||||
|
writeError(w, r, http.StatusConflict, 41003, "IM_RELATION_PENDING", "customer service relationship is pending", nil)
|
||||||
|
case errors.Is(err, support.ErrRelationDenied):
|
||||||
|
writeError(w, r, http.StatusForbidden, 41004, "IM_RELATION_NOT_ALLOWED", "customer service relationship is not allowed", nil)
|
||||||
|
case errors.Is(err, support.ErrIMUnavailable):
|
||||||
|
writeError(w, r, http.StatusServiceUnavailable, 41005, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
|
||||||
|
default:
|
||||||
|
writeError(w, r, http.StatusBadGateway, 41006, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
"git.118im.com/im_team/olons_server/internal/auth"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
type taskClaimRequest struct {
|
type taskClaimRequest struct {
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package imclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxResponseBody = 2 << 20
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Status int
|
||||||
|
Code int
|
||||||
|
Reason string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return "IM service error"
|
||||||
|
}
|
||||||
|
if e.Reason != "" {
|
||||||
|
return e.Reason
|
||||||
|
}
|
||||||
|
if e.Message != "" {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("IM service returned HTTP %d", e.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg config.IMConfig
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatedUser struct {
|
||||||
|
UserID FlexibleID `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexibleID normalizes the IM internal API's numeric database ID and its
|
||||||
|
// public string user ID into one representation. User creation returns a
|
||||||
|
// numeric ID, while session issuance and product APIs return the public ID.
|
||||||
|
type FlexibleID string
|
||||||
|
|
||||||
|
func (id *FlexibleID) UnmarshalJSON(data []byte) error {
|
||||||
|
var value string
|
||||||
|
if len(data) > 0 && data[0] == '"' {
|
||||||
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
if value == "" || value == "null" {
|
||||||
|
return errors.New("empty IM user ID")
|
||||||
|
}
|
||||||
|
*id = FlexibleID(value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserProfile struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Capabilities struct {
|
||||||
|
CanSend bool `json:"can_send"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FriendRequest struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Conversation struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
CanSend bool `json:"can_send"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.IMConfig) *Client {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 8 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, http: &http.Client{Timeout: timeout}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Config() config.IMConfig { return c.cfg }
|
||||||
|
|
||||||
|
func (c *Client) Ready() bool {
|
||||||
|
return strings.TrimSpace(c.cfg.BaseURL) != "" && strings.TrimSpace(c.cfg.HTTPAPIURL) != "" &&
|
||||||
|
strings.TrimSpace(c.cfg.WebSocketURL) != "" &&
|
||||||
|
strings.TrimSpace(c.cfg.TenantCode) != "" && strings.TrimSpace(c.cfg.APIKey) != "" &&
|
||||||
|
strings.TrimSpace(c.cfg.InternalService) != "" && strings.TrimSpace(c.cfg.InternalSecret) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateUser(ctx context.Context, username, nickname, password string) (*CreatedUser, error) {
|
||||||
|
var out CreatedUser
|
||||||
|
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users"
|
||||||
|
err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{
|
||||||
|
"username": username, "nickname": nickname, "password": password,
|
||||||
|
}, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FindUserByUsername(ctx context.Context, username string) (*CreatedUser, error) {
|
||||||
|
query := url.Values{"auth_name": []string{username}, "page": []string{"1"}, "per_page": []string{"20"}}
|
||||||
|
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users?" + query.Encode()
|
||||||
|
var page struct {
|
||||||
|
Items []struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
if err := c.internalJSON(ctx, http.MethodGet, path, nil, &page); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range page.Items {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(item.Username), strings.TrimSpace(username)) {
|
||||||
|
return &CreatedUser{UserID: FlexibleID(item.UserID), Username: item.Username, Nickname: item.Nickname}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &Error{Status: http.StatusNotFound, Reason: "RESOURCE_NOT_FOUND", Message: "IM user not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, platform string) (*Session, error) {
|
||||||
|
endpoint, err := url.Parse(strings.TrimSpace(c.cfg.WebSocketURL))
|
||||||
|
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
|
||||||
|
return nil, errors.New("IM websocket URL is invalid")
|
||||||
|
}
|
||||||
|
query := endpoint.Query()
|
||||||
|
query.Set("apikey", c.cfg.APIKey)
|
||||||
|
endpoint.RawQuery = query.Encode()
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: c.cfg.Timeout}
|
||||||
|
conn, _, err := dialer.DialContext(ctx, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
deadline := time.Now().Add(c.cfg.Timeout)
|
||||||
|
_ = conn.SetWriteDeadline(deadline)
|
||||||
|
_ = conn.SetReadDeadline(deadline)
|
||||||
|
hiID := "hi-" + requestID()
|
||||||
|
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{
|
||||||
|
"id": hiID, "ver": "0.25", "ua": "olons-server/1.0", "dev": deviceID,
|
||||||
|
"platf": firstNonEmpty(platform, "server"), "lang": "zh-CN", "tenant": c.cfg.TenantCode,
|
||||||
|
}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err = readCtrl(conn, hiID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
loginID := "login-" + requestID()
|
||||||
|
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||||
|
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,
|
||||||
|
}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrl, err := readCtrl(conn, loginID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
expiresAt, _ := time.Parse(time.RFC3339, ctrl.Params.Expires)
|
||||||
|
if ctrl.Params.User == "" || ctrl.Params.Token == "" {
|
||||||
|
return nil, errors.New("IM login response is incomplete")
|
||||||
|
}
|
||||||
|
return &Session{UserID: ctrl.Params.User, Token: ctrl.Params.Token, ExpiresAt: expiresAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ResetPassword(ctx context.Context, userID, password string) error {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action"
|
||||||
|
return c.internalJSON(ctx, http.MethodPost, path, map[string]any{
|
||||||
|
"event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "Olons客服系统凭据同步",
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UserProfile(ctx context.Context, token, userID string) (*UserProfile, error) {
|
||||||
|
var out UserProfile
|
||||||
|
err := c.productJSON(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/profile", token, "", nil, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempotencyKey string) (*FriendRequest, error) {
|
||||||
|
var out FriendRequest
|
||||||
|
err := c.productJSON(ctx, http.MethodPost, "/friend-requests", token, idempotencyKey, map[string]any{
|
||||||
|
"target_user_id": userID, "message": "咨询客服", "source": "profile_card",
|
||||||
|
}, &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,
|
||||||
|
map[string]any{"peer_user_id": userID}, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) internalJSON(ctx context.Context, method, path string, input, output any) error {
|
||||||
|
body, err := marshalBody(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
|
||||||
|
if baseURL == "" || c.cfg.InternalSecret == "" {
|
||||||
|
return errors.New("IM internal API is not configured")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
|
||||||
|
nonce, err := randomNonce()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyHash := sha256.Sum256(body)
|
||||||
|
canonical := strings.Join([]string{method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce}, "\n")
|
||||||
|
mac := hmac.New(sha256.New, []byte(c.cfg.InternalSecret))
|
||||||
|
_, _ = mac.Write([]byte(canonical))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Service-Name", c.cfg.InternalService)
|
||||||
|
req.Header.Set("X-Timestamp", timestamp)
|
||||||
|
req.Header.Set("X-Nonce", nonce)
|
||||||
|
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
return c.do(req, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) productJSON(ctx context.Context, method, path, token, idempotencyKey string, input, output any) error {
|
||||||
|
body, err := marshalBody(input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimRight(c.cfg.HTTPAPIURL, "/")
|
||||||
|
if baseURL == "" || c.cfg.APIKey == "" {
|
||||||
|
return errors.New("IM product API is not configured")
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-MeetingIM-APIKey", c.cfg.APIKey)
|
||||||
|
req.Header.Set("X-IM-Schema", "1")
|
||||||
|
req.Header.Set("X-Request-ID", requestID())
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
req.Header.Set("Idempotency-Key", idempotencyKey)
|
||||||
|
}
|
||||||
|
return c.do(req, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(req *http.Request, output any) error {
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||||
|
return &Error{Status: resp.StatusCode, Reason: "INVALID_RESPONSE", Message: "invalid IM response"}
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 {
|
||||||
|
return &Error{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message}
|
||||||
|
}
|
||||||
|
if output == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(envelope.Data, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalBody(input any) ([]byte, error) {
|
||||||
|
if input == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomNonce() (string, error) {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := cryptorand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestID() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := cryptorand.Read(buf); err == nil {
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
type websocketCtrl struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Params struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expires string `json:"expires"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCtrl(conn *websocket.Conn, expectedID string) (*websocketCtrl, error) {
|
||||||
|
for {
|
||||||
|
var packet struct {
|
||||||
|
Ctrl *websocketCtrl `json:"ctrl"`
|
||||||
|
}
|
||||||
|
if err := conn.ReadJSON(&packet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if packet.Ctrl == nil || packet.Ctrl.ID != expectedID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if packet.Ctrl.Code >= 300 {
|
||||||
|
reason := firstNonEmpty(packet.Ctrl.Params.Reason, packet.Ctrl.Text, "IM websocket request failed")
|
||||||
|
return nil, &Error{Status: packet.Ctrl.Code, Reason: reason, Message: packet.Ctrl.Text}
|
||||||
|
}
|
||||||
|
return packet.Ctrl, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsConflict(err error) bool {
|
||||||
|
var target *Error
|
||||||
|
return errors.As(err, &target) && target.Status == http.StatusConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func IsAuthenticationFailed(err error) bool {
|
||||||
|
var target *Error
|
||||||
|
return errors.As(err, &target) && target.Status == http.StatusUnauthorized
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package imclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateUserAcceptsNumericIDAndSignsRequest(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.URL.Path != "/internal/admin-query/tenants/allrich/users" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-Service-Name") != "olons-server" {
|
||||||
|
t.Fatalf("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.Fatalf("invalid signature")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":42,"username":"allrich_m_7","nickname":"会员7"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
|
||||||
|
APIKey: "public-key", InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
|
||||||
|
created, err := client.CreateUser(context.Background(), "allrich_m_7", "会员7", "password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := string(created.UserID); got != "42" {
|
||||||
|
t.Fatalf("user id = %q, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" {
|
||||||
|
t.Fatalf("missing product API credentials")
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-IM-Schema") != "1" {
|
||||||
|
t.Fatalf("missing schema header")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":"usr_agent","relationship":"friend","topic":"p2p123","capabilities":{"can_send":true}}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
|
||||||
|
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
|
||||||
|
profile, err := client.UserProfile(context.Background(), "user-token", "usr_agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if profile.Relationship != "friend" || !profile.Capabilities.CanSend {
|
||||||
|
t.Fatalf("unexpected profile: %+v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("apikey") != "public-key" {
|
||||||
|
t.Fatalf("missing API key")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if packet["hi"]["tenant"] != "allrich" {
|
||||||
|
t.Fatalf("unexpected hi: %#v", packet)
|
||||||
|
}
|
||||||
|
_ = 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"]
|
||||||
|
secret, _ := base64.StdEncoding.DecodeString(login["secret"].(string))
|
||||||
|
if string(secret) != "member01:password" || login["login_type"] != float64(0) || login["tenant_code"] != "allrich" {
|
||||||
|
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: "allrich",
|
||||||
|
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
|
||||||
|
session, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if session.UserID != "usr_member" || session.Token != "token" {
|
||||||
|
t.Fatalf("unexpected session: %+v", session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := base64.StdEncoding.DecodeString(body.PasswordHash)
|
||||||
|
if body.EventType != "user.password.reset" || err != nil || bcrypt.CompareHashAndPassword(hash, []byte("new-password")) != nil {
|
||||||
|
t.Fatalf("unexpected reset body: %+v", body)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"code":0,"data":{"status":"succeeded"}}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, WebSocketURL: "ws://example.test",
|
||||||
|
TenantCode: "allrich", APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
|
||||||
|
if err := client.ResetPassword(context.Background(), "usr_member", "new-password"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashHex(body []byte) string {
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
return hex.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,6 +22,8 @@ type Repository interface {
|
|||||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||||
|
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||||
|
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||||
@@ -51,6 +53,7 @@ type Notice struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
BodyHTML string `json:"body_html,omitempty"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
StartAt string `json:"start_at"`
|
StartAt string `json:"start_at"`
|
||||||
EndAt string `json:"end_at"`
|
EndAt string `json:"end_at"`
|
||||||
@@ -124,6 +127,7 @@ type CustomerService struct {
|
|||||||
ServiceURL string `json:"service_url"`
|
ServiceURL string `json:"service_url"`
|
||||||
BackupContact string `json:"backup_contact"`
|
BackupContact string `json:"backup_contact"`
|
||||||
PrivacyNotice string `json:"privacy_notice"`
|
PrivacyNotice string `json:"privacy_notice"`
|
||||||
|
IMEnabled bool `json:"im_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository) *Service {
|
func New(repository Repository) *Service {
|
||||||
@@ -203,11 +207,42 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
|
|||||||
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||||
|
row, err := s.repository.PublishedContent(ctx, tenantID, "home_popup_content")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(row.BodyText)
|
||||||
|
if content == "" {
|
||||||
|
content = strings.TrimSpace(row.Summary)
|
||||||
|
}
|
||||||
|
return &Notice{ID: 0, Title: row.Title, Content: content, BodyHTML: row.BodyHTML,
|
||||||
|
SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||||
|
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||||
key = strings.ToLower(strings.TrimSpace(key))
|
key = strings.ToLower(strings.TrimSpace(key))
|
||||||
if !contentKeyPattern.MatchString(key) {
|
if !contentKeyPattern.MatchString(key) {
|
||||||
return nil, ErrContentKeyInvalid
|
return nil, ErrContentKeyInvalid
|
||||||
}
|
}
|
||||||
|
requestedKey := key
|
||||||
|
titleOverride := ""
|
||||||
|
switch key {
|
||||||
|
case "platform_intro", "user_agreement":
|
||||||
|
key = "platform_introduction"
|
||||||
|
if requestedKey == "user_agreement" {
|
||||||
|
titleOverride = "用户协议"
|
||||||
|
}
|
||||||
|
case "privacy_policy":
|
||||||
|
// 当前后台只维护原型的 12 条正式文本,隐私条款沿用平台协议正文,
|
||||||
|
// 避免为了前端路由额外制造一条无法在原型中对应的初始化数据。
|
||||||
|
key = "platform_introduction"
|
||||||
|
titleOverride = "隐私政策"
|
||||||
|
}
|
||||||
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -216,7 +251,11 @@ func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Con
|
|||||||
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
||||||
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
|
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
|
||||||
}
|
}
|
||||||
return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title,
|
title := row.Title
|
||||||
|
if titleOverride != "" {
|
||||||
|
title = titleOverride
|
||||||
|
}
|
||||||
|
return &Content{ContentKey: requestedKey, ContentType: row.ContentType, Title: title,
|
||||||
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
||||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||||
}
|
}
|
||||||
@@ -266,6 +305,7 @@ func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]Custo
|
|||||||
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
|
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
|
||||||
BackupContact: row.BackupContact,
|
BackupContact: row.BackupContact,
|
||||||
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
|
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
|
||||||
|
IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
@@ -42,6 +42,15 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
|
|||||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||||
return r.notices, nil
|
return r.notices, nil
|
||||||
}
|
}
|
||||||
|
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||||
|
if len(r.notices) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &r.notices[0], nil
|
||||||
|
}
|
||||||
|
func (r fakeRepository) MarkMemberNoticeRead(context.Context, int64, int64, int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
|
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
|
||||||
if r.noticePage == nil {
|
if r.noticePage == nil {
|
||||||
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
|
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
|
||||||
@@ -193,3 +202,30 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
|
|||||||
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
|
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPopupNoticeUsesManagedContent(t *testing.T) {
|
||||||
|
service := New(fakeRepository{content: &store.Content{
|
||||||
|
ContentKey: "home_popup_content", Title: "公告通知", BodyText: "新活动已上线",
|
||||||
|
PublishedAt: "2026-10-16T08:00:00Z",
|
||||||
|
}})
|
||||||
|
notice, err := service.PopupNotice(context.Background(), 1, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if notice == nil || notice.ID != 0 || notice.Content != "新活动已上线" || notice.SourceType != "managed_content" {
|
||||||
|
t.Fatalf("unexpected popup notice: %+v", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentAliasesPublicAgreementRoutesToManagedText(t *testing.T) {
|
||||||
|
service := New(fakeRepository{content: &store.Content{
|
||||||
|
ContentKey: "platform_introduction", Title: "平台简介", BodyText: "服务条款正文",
|
||||||
|
}})
|
||||||
|
content, err := service.Content(context.Background(), 1, "user_agreement")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if content.ContentKey != "user_agreement" || content.Title != "用户协议" || content.BodyText != "服务条款正文" {
|
||||||
|
t.Fatalf("unexpected aliased content: %+v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
|||||||
(tenant_id,member_no,username,mobile,password_hash,
|
(tenant_id,member_no,username,mobile,password_hash,
|
||||||
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
||||||
credit_score,is_test_account,created_by,updated_by)
|
credit_score,is_test_account,created_by,updated_by)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
|
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,1,100,2,0,0)`,
|
||||||
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
||||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDuplicate(err) {
|
if isDuplicate(err) {
|
||||||
|
if strings.Contains(err.Error(), "uk_app_member_invite_code") {
|
||||||
|
return nil, ErrInviteCodeExists
|
||||||
|
}
|
||||||
return nil, ErrAccountExists
|
return nil, ErrAccountExists
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -129,7 +132,7 @@ func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile str
|
|||||||
func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) {
|
func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) {
|
||||||
code = strings.TrimSpace(code)
|
code = strings.TrimSpace(code)
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return 0, 1, "", nil
|
return 0, 0, "", ErrReferralInvalid
|
||||||
}
|
}
|
||||||
var parent struct {
|
var parent struct {
|
||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type AttachmentCreation struct {
|
||||||
|
MemberID int64
|
||||||
|
Name string
|
||||||
|
MimeType string
|
||||||
|
Path string
|
||||||
|
URL string
|
||||||
|
Size int64
|
||||||
|
Ext string
|
||||||
|
MD5 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
|
||||||
|
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
|
||||||
|
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
|
||||||
|
VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
|
||||||
|
input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Attachment{ID: id, URL: input.URL}, nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import "errors"
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAccountExists = errors.New("account already exists")
|
ErrAccountExists = errors.New("account already exists")
|
||||||
|
ErrInviteCodeExists = errors.New("invite code already exists")
|
||||||
ErrAccountNotFound = errors.New("account not found")
|
ErrAccountNotFound = errors.New("account not found")
|
||||||
ErrAccountRestricted = errors.New("account restricted")
|
ErrAccountRestricted = errors.New("account restricted")
|
||||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/config"
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ type CustomerService struct {
|
|||||||
OnlineStatus int `db:"online_status"`
|
OnlineStatus int `db:"online_status"`
|
||||||
ServiceURL string `db:"service_url"`
|
ServiceURL string `db:"service_url"`
|
||||||
BackupContact string `db:"backup_contact"`
|
BackupContact string `db:"backup_contact"`
|
||||||
|
IMTenantCode string `db:"im_tenant_code"`
|
||||||
|
IMUserID string `db:"im_user_id"`
|
||||||
|
IMUsername string `db:"im_username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
||||||
@@ -161,6 +164,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) LatestPopupNotice(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)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return errors.New("store is unavailable")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrResourceNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
|
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, errors.New("store is unavailable")
|
return nil, errors.New("store is unavailable")
|
||||||
@@ -222,7 +268,8 @@ func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]C
|
|||||||
}
|
}
|
||||||
rows := make([]CustomerService, 0)
|
rows := make([]CustomerService, 0)
|
||||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time,
|
err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time,
|
||||||
online_status,service_url,backup_contact FROM hg_customer_service
|
online_status,service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
|
||||||
|
COALESCE(im_username,'') AS im_username FROM hg_customer_service
|
||||||
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
|
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
|
||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type MemberOverview struct {
|
|||||||
Username string `db:"username"`
|
Username string `db:"username"`
|
||||||
Mobile string `db:"mobile"`
|
Mobile string `db:"mobile"`
|
||||||
InviteCode string `db:"invite_code"`
|
InviteCode string `db:"invite_code"`
|
||||||
|
HasTradePassword int `db:"has_trade_password"`
|
||||||
MemberStatus int `db:"member_status"`
|
MemberStatus int `db:"member_status"`
|
||||||
MemberCategory int `db:"member_category"`
|
MemberCategory int `db:"member_category"`
|
||||||
CreditScore int `db:"credit_score"`
|
CreditScore int `db:"credit_score"`
|
||||||
@@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
|
|||||||
}
|
}
|
||||||
var row MemberOverview
|
var row MemberOverview
|
||||||
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile,
|
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile,
|
||||||
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score,
|
m.invite_code,CASE WHEN m.trade_password_hash <> '' THEN 1 ELSE 0 END AS has_trade_password,
|
||||||
|
m.member_status,m.risk_status AS member_category,m.credit_score,
|
||||||
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
|
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
|
||||||
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
|
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
|
||||||
COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
|
COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
|
||||||
@@ -92,7 +94,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
|
|||||||
|
|
||||||
var avatar Avatar
|
var avatar Avatar
|
||||||
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
|
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
|
||||||
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1
|
WHERE id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrAttachmentInvalid
|
return nil, ErrAttachmentInvalid
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ type RechargePage struct {
|
|||||||
Total int64
|
Total int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rechargeStatusReview = 2
|
||||||
|
|
||||||
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||||
items := make([]RechargeChannel, 0)
|
items := make([]RechargeChannel, 0)
|
||||||
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||||
@@ -95,7 +97,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
|||||||
for _, attachmentID := range input.VoucherAttachmentIDs {
|
for _, attachmentID := range input.VoucherAttachmentIDs {
|
||||||
var count int
|
var count int
|
||||||
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
|
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
|
||||||
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
AND app_id='olons_server' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
@@ -109,8 +111,8 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
|||||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
|
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
|
||||||
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
|
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
|
||||||
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
|
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
|
||||||
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
|
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -123,7 +125,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
|||||||
}
|
}
|
||||||
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
|
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
|
||||||
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
|
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||||
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
|
VoucherAttachmentIDs: vouchers, Status: rechargeStatusReview, Remark: input.Remark}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
|
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemberIMIdentity struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
TenantID int64 `db:"tenant_id"`
|
||||||
|
MemberID int64 `db:"member_id"`
|
||||||
|
IMTenantCode string `db:"im_tenant_code"`
|
||||||
|
IMUserID string `db:"im_user_id"`
|
||||||
|
IMUsername string `db:"im_username"`
|
||||||
|
IMPasswordCipher string `db:"im_password_cipher"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberIMProfile struct {
|
||||||
|
MemberID int64 `db:"member_id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
MemberNo string `db:"member_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CustomerServiceByID(ctx context.Context, tenantID, serviceID int64) (*CustomerService, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("store is unavailable")
|
||||||
|
}
|
||||||
|
var row CustomerService
|
||||||
|
err := s.db.GetContext(ctx, &row, `SELECT id,service_code,name,avatar_url,work_time,online_status,
|
||||||
|
service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
|
||||||
|
COALESCE(im_username,'') AS im_username FROM hg_customer_service
|
||||||
|
WHERE tenant_id=? AND id=? AND status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, serviceID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MemberIMIdentity(ctx context.Context, tenantID, memberID int64) (*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)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MemberIMProfile(ctx context.Context, tenantID, memberID int64) (*MemberIMProfile, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("store is unavailable")
|
||||||
|
}
|
||||||
|
var row MemberIMProfile
|
||||||
|
err := s.db.GetContext(ctx, &row, `SELECT id AS member_id,username,member_no FROM hg_app_member
|
||||||
|
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrAccountNotFound
|
||||||
|
}
|
||||||
|
return &row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveMemberIMIdentity(ctx context.Context, identity *MemberIMIdentity) (*MemberIMIdentity, error) {
|
||||||
|
if s == nil || s.db == nil {
|
||||||
|
return nil, errors.New("store is unavailable")
|
||||||
|
}
|
||||||
|
if identity == nil {
|
||||||
|
return nil, errors.New("identity is required")
|
||||||
|
}
|
||||||
|
_, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_im_identity
|
||||||
|
(tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher,sync_status,last_error,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,1,'',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))
|
||||||
|
ON DUPLICATE KEY UPDATE im_user_id=VALUES(im_user_id),im_username=VALUES(im_username),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// A concurrent request may have completed the same deterministic mapping.
|
||||||
|
existing, findErr := s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
|
||||||
|
if findErr == nil {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
+411
-49
@@ -2,9 +2,14 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
@@ -35,6 +40,7 @@ type TaskHall struct {
|
|||||||
type TaskEligibility struct {
|
type TaskEligibility struct {
|
||||||
MemberLevelID int64 `db:"member_level_id"`
|
MemberLevelID int64 `db:"member_level_id"`
|
||||||
MemberStatus int `db:"member_status"`
|
MemberStatus int `db:"member_status"`
|
||||||
|
AutoDispatch int `db:"auto_dispatch"`
|
||||||
HallEligible bool `db:"hall_eligible"`
|
HallEligible bool `db:"hall_eligible"`
|
||||||
AvailableAmount int64 `db:"available_amount"`
|
AvailableAmount int64 `db:"available_amount"`
|
||||||
WalletStatus int `db:"wallet_status"`
|
WalletStatus int `db:"wallet_status"`
|
||||||
@@ -48,6 +54,7 @@ type ClaimTaskInput struct {
|
|||||||
MemberID int64
|
MemberID int64
|
||||||
HallID int64
|
HallID int64
|
||||||
MinimumAmount int64
|
MinimumAmount int64
|
||||||
|
AutoDelay int
|
||||||
DeadlineAt time.Time
|
DeadlineAt time.Time
|
||||||
OrderNo string
|
OrderNo string
|
||||||
}
|
}
|
||||||
@@ -110,6 +117,9 @@ type TaskSubmissionInput struct {
|
|||||||
SubmissionNo string
|
SubmissionNo string
|
||||||
EvidenceText string
|
EvidenceText string
|
||||||
AttachmentIDs []int64
|
AttachmentIDs []int64
|
||||||
|
LedgerNo string
|
||||||
|
IdempotencyKey string
|
||||||
|
SettledAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskSubmissionResult struct {
|
type TaskSubmissionResult struct {
|
||||||
@@ -130,7 +140,7 @@ func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]Task
|
|||||||
|
|
||||||
func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) {
|
func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) {
|
||||||
var row TaskEligibility
|
var row TaskEligibility
|
||||||
err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,
|
err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,m.auto_dispatch,
|
||||||
EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl
|
EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl
|
||||||
ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
|
||||||
WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible,
|
WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible,
|
||||||
@@ -160,8 +170,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
|
|
||||||
var member struct {
|
var member struct {
|
||||||
LevelID int64 `db:"level_id"`
|
LevelID int64 `db:"level_id"`
|
||||||
|
AutoDispatch int `db:"auto_dispatch"`
|
||||||
}
|
}
|
||||||
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member
|
err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member
|
||||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrAccountNotFound
|
return nil, ErrAccountNotFound
|
||||||
@@ -194,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
if wallet.Status != 1 {
|
if wallet.Status != 1 {
|
||||||
return nil, ErrAccountRestricted
|
return nil, ErrAccountRestricted
|
||||||
}
|
}
|
||||||
if wallet.AvailableAmount < input.MinimumAmount {
|
if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) {
|
||||||
return nil, ErrInsufficientBalance
|
return nil, ErrInsufficientBalance
|
||||||
}
|
}
|
||||||
var pending int64
|
var pending int64
|
||||||
@@ -207,8 +218,12 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
if pending > 0 {
|
if pending > 0 {
|
||||||
return nil, ErrPendingOrderExists
|
return nil, ErrPendingOrderExists
|
||||||
}
|
}
|
||||||
var dailyLimit int
|
var levelRule struct {
|
||||||
err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l
|
DailyLimit int `db:"daily_order_limit"`
|
||||||
|
RuleJSON json.RawMessage `db:"rule_json"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &levelRule, `SELECT COALESCE(v.daily_order_limit,0) AS daily_order_limit,
|
||||||
|
COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json FROM hg_app_member_level l
|
||||||
LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
|
LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
|
||||||
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID)
|
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -217,43 +232,52 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if dailyLimit > 0 {
|
if levelRule.DailyLimit > 0 {
|
||||||
var todayCount int
|
var todayCount int
|
||||||
if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=?
|
if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=?
|
||||||
AND order_type=2 AND created_at>=UTC_DATE() AND created_at<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil {
|
AND order_type=2 AND created_at>=UTC_DATE() AND created_at<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if todayCount >= dailyLimit {
|
if todayCount >= levelRule.DailyLimit {
|
||||||
return nil, ErrDailyLimitReached
|
return nil, ErrDailyLimitReached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var task struct {
|
dispatchMode, message := "manual", "等待后台匹配订单"
|
||||||
ID int64 `db:"id"`
|
matchAt := time.Now().UTC()
|
||||||
Title string `db:"title"`
|
deadlineAt := input.DeadlineAt.UTC()
|
||||||
CoverURL string `db:"cover_url"`
|
delaySeconds := 0
|
||||||
CommissionAmount int64 `db:"commission_amount"`
|
if member.AutoDispatch == 1 {
|
||||||
CommissionRate int `db:"commission_rate"`
|
dispatchMode, message = "auto", "正在自动匹配订单"
|
||||||
PerMemberLimit int `db:"per_member_limit"`
|
delayMin, delayMax := input.AutoDelay, input.AutoDelay
|
||||||
|
var levelRules map[string]any
|
||||||
|
if json.Unmarshal(levelRule.RuleJSON, &levelRules) == nil {
|
||||||
|
if value, ok := jsonInt(levelRules["auto_order_delay_min_seconds"]); ok {
|
||||||
|
delayMin = value
|
||||||
}
|
}
|
||||||
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
|
if value, ok := jsonInt(levelRules["auto_order_delay_max_seconds"]); ok {
|
||||||
FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=?
|
delayMax = value
|
||||||
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL
|
}
|
||||||
AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3))
|
}
|
||||||
AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id
|
if delayMin < 0 {
|
||||||
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
|
delayMin = 0
|
||||||
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id
|
}
|
||||||
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit)
|
if delayMax < delayMin {
|
||||||
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
|
delayMax = delayMin
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
}
|
||||||
return nil, ErrNoTaskAvailable
|
delaySeconds = randomDelaySeconds(delayMin, delayMax)
|
||||||
|
matchAt = matchAt.Add(time.Duration(delaySeconds) * time.Second)
|
||||||
|
completionWindow := input.DeadlineAt.UTC().Sub(time.Now().UTC())
|
||||||
|
if completionWindow > 0 {
|
||||||
|
deadlineAt = matchAt.Add(completionWindow)
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
rules, err := json.Marshal(map[string]any{
|
rules, err := json.Marshal(map[string]any{
|
||||||
"commission_amount": task.CommissionAmount,
|
"source": "member_auto_claim",
|
||||||
"commission_rate": task.CommissionRate,
|
"dispatch_mode": dispatchMode,
|
||||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
"hall_id": input.HallID,
|
||||||
|
"deadline_at": deadlineAt.Format(time.RFC3339),
|
||||||
|
"scheduled_match_at": matchAt.Format(time.RFC3339),
|
||||||
|
"dispatch_delay_seconds": delaySeconds,
|
||||||
"member_level_id": member.LevelID,
|
"member_level_id": member.LevelID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,10 +286,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
||||||
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
|
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
|
||||||
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
||||||
message,order_status,freeze_status,matched_at,created_by,updated_by)
|
message,order_status,freeze_status,created_by,updated_by)
|
||||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
|
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
|
||||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
|
||||||
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDuplicate(err) {
|
if isDuplicate(err) {
|
||||||
return nil, ErrPendingOrderExists
|
return nil, ErrPendingOrderExists
|
||||||
@@ -279,11 +302,209 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
|
||||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
|
||||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
|
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: deadlineAt.Format(time.RFC3339),
|
||||||
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
}
|
||||||
|
|
||||||
|
func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orderID int64, delaySeconds int, minAmount, maxAmount int64) error {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var order struct {
|
||||||
|
OrderNo string `db:"order_no"`
|
||||||
|
Status int `db:"order_status"`
|
||||||
|
MemberLevelID int64 `db:"member_level_id"`
|
||||||
|
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||||
|
AutoDispatch int `db:"auto_dispatch"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &order, `SELECT o.order_no,o.order_status,o.member_level_id,o.rule_snapshot,o.created_at,m.auto_dispatch
|
||||||
|
FROM hg_trade_order o JOIN hg_app_member m ON m.tenant_id=o.tenant_id AND m.id=o.member_id
|
||||||
|
WHERE o.tenant_id=? AND o.member_id=? AND o.id=? AND o.order_type=2 LIMIT 1 FOR UPDATE`, tenantID, memberID, orderID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrOrderNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.Status != orderStatusMatching {
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
matchAt := order.CreatedAt.UTC().Add(time.Duration(delaySeconds) * time.Second)
|
||||||
|
snapshot := map[string]any{}
|
||||||
|
if len(order.RuleSnapshot) > 0 && json.Unmarshal(order.RuleSnapshot, &snapshot) == nil {
|
||||||
|
if raw, ok := snapshot["scheduled_match_at"].(string); ok {
|
||||||
|
if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil {
|
||||||
|
matchAt = parsed.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatchMode, _ := snapshot["dispatch_mode"].(string)
|
||||||
|
isAutoDispatch := dispatchMode == "auto" || dispatchMode == "" && order.AutoDispatch == 1
|
||||||
|
if !isAutoDispatch || time.Now().UTC().Before(matchAt) {
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
var account struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
AvailableAmount int64 `db:"available_amount"`
|
||||||
|
FrozenAmount int64 `db:"frozen_amount"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,version FROM hg_wallet_account
|
||||||
|
WHERE tenant_id=? AND member_id=? AND currency='CNY' AND account_status=1 LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrAccountRestricted
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
lowerAmount := max(int64(1), minAmount)
|
||||||
|
upperAmount := account.AvailableAmount
|
||||||
|
if maxAmount > 0 && maxAmount < upperAmount {
|
||||||
|
upperAmount = maxAmount
|
||||||
|
}
|
||||||
|
if lowerAmount > upperAmount {
|
||||||
|
return ErrNoTaskAvailable
|
||||||
|
}
|
||||||
|
var product struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
ProductNo string `db:"product_no"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
PriceAmount int64 `db:"price_amount"`
|
||||||
|
Currency string `db:"currency"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &product, `SELECT id,product_no,name,price_amount,currency FROM hg_product
|
||||||
|
WHERE tenant_id=? AND publish_status=3 AND deleted_at IS NULL AND currency='CNY' AND price_amount>=? AND price_amount<=?
|
||||||
|
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
|
||||||
|
ORDER BY RAND() LIMIT 1 FOR UPDATE`, tenantID, lowerAmount, upperAmount)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrNoTaskAvailable
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var levelRule struct {
|
||||||
|
VersionID int64 `db:"version_id"`
|
||||||
|
CommissionRate int `db:"commission_rate"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &levelRule, `SELECT v.id AS version_id,v.commission_rate
|
||||||
|
FROM hg_app_member_level l JOIN hg_app_member_level_version v
|
||||||
|
ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
|
||||||
|
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, tenantID, order.MemberLevelID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrLevelNotEligible
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot["match_source"] = "system_auto_match"
|
||||||
|
snapshot["product_id"] = product.ID
|
||||||
|
snapshot["product_no"] = product.ProductNo
|
||||||
|
snapshot["unit_price_amount"] = product.PriceAmount
|
||||||
|
snapshot["quantity"] = 1
|
||||||
|
snapshot["level_version_id"] = levelRule.VersionID
|
||||||
|
snapshot["commission_rate"] = levelRule.CommissionRate
|
||||||
|
rules, err := json.Marshal(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000
|
||||||
|
holdAvailable := account.AvailableAmount - product.PriceAmount
|
||||||
|
holdFrozen := account.FrozenAmount + product.PriceAmount
|
||||||
|
settledAvailable := account.AvailableAmount + commissionAmount
|
||||||
|
now := time.Now().UTC()
|
||||||
|
walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,version=version+1,
|
||||||
|
updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`,
|
||||||
|
settledAvailable, account.FrozenAmount, commissionAmount, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||||
|
return ErrConcurrentUpdate
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID),
|
||||||
|
"task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, holdAvailable,
|
||||||
|
"match:available", "自动派单扣除可用余额"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID),
|
||||||
|
"task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, holdFrozen,
|
||||||
|
"match:frozen", "自动派单冻结订单本金"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSF", orderID),
|
||||||
|
"task_principal_release", order.OrderNo, "frozen", 2, product.PriceAmount, holdFrozen, account.FrozenAmount,
|
||||||
|
"settle:frozen", "自动订单完成释放冻结本金"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSA", orderID),
|
||||||
|
"task_principal_return", order.OrderNo, "available", 1, product.PriceAmount, holdAvailable, account.AvailableAmount,
|
||||||
|
"settle:available", "自动订单完成返回本金"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
submissionNo := fmt.Sprintf("TSAUTO%d", orderID)
|
||||||
|
commissionLedgerID, err := insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSC", orderID),
|
||||||
|
"commission", submissionNo, "available", 1, commissionAmount, account.AvailableAmount, settledAvailable,
|
||||||
|
"settle:commission", "自动订单完成返佣")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||||
|
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
||||||
|
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
|
||||||
|
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,'',JSON_ARRAY(),4,?,10000,0,?,'AUTO_MATCHED','自动派单完成',2,?,?,0,0,?,?)`,
|
||||||
|
tenantID, submissionNo, orderID, 0, memberID, order.MemberLevelID, commissionAmount, now,
|
||||||
|
commissionLedgerID, now, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1,
|
||||||
|
unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?,
|
||||||
|
order_status=4,freeze_status=1,matched_at=?,completed_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name,
|
||||||
|
product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules,
|
||||||
|
"系统已自动匹配并完成订单,佣金已入账", now, now, now, tenantID, memberID, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||||
|
return ErrConcurrentUpdate
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonInt(value any) (int, bool) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(typed), true
|
||||||
|
case json.Number:
|
||||||
|
parsed, err := typed.Int64()
|
||||||
|
return int(parsed), err == nil
|
||||||
|
case string:
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||||
|
return parsed, err == nil
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDelaySeconds(minimum, maximum int) int {
|
||||||
|
if maximum <= minimum {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(maximum-minimum+1)))
|
||||||
|
if err != nil {
|
||||||
|
return minimum
|
||||||
|
}
|
||||||
|
return minimum + int(value.Int64())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||||
@@ -364,13 +585,16 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var order struct {
|
var order struct {
|
||||||
|
OrderNo string `db:"order_no"`
|
||||||
TaskID int64 `db:"task_id"`
|
TaskID int64 `db:"task_id"`
|
||||||
MemberLevelID int64 `db:"member_level_id"`
|
MemberLevelID int64 `db:"member_level_id"`
|
||||||
|
TotalAmount int64 `db:"total_amount"`
|
||||||
CommissionAmount int64 `db:"commission_amount"`
|
CommissionAmount int64 `db:"commission_amount"`
|
||||||
OrderStatus int `db:"order_status"`
|
OrderStatus int `db:"order_status"`
|
||||||
DeadlineAt string `db:"deadline_at"`
|
DeadlineAt string `db:"deadline_at"`
|
||||||
|
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||||
}
|
}
|
||||||
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
|
err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,rule_snapshot,
|
||||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
|
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
|
||||||
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
|
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
|
||||||
input.TenantID, input.MemberID, input.OrderID)
|
input.TenantID, input.MemberID, input.OrderID)
|
||||||
@@ -383,8 +607,91 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
|||||||
if order.OrderStatus != orderStatusPending {
|
if order.OrderStatus != orderStatusPending {
|
||||||
return nil, ErrOrderState
|
return nil, ErrOrderState
|
||||||
}
|
}
|
||||||
|
var account struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
AvailableAmount int64 `db:"available_amount"`
|
||||||
|
FrozenAmount int64 `db:"frozen_amount"`
|
||||||
|
Status int `db:"account_status"`
|
||||||
|
Version int `db:"version"`
|
||||||
|
}
|
||||||
|
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,account_status,version FROM hg_wallet_account
|
||||||
|
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrAccountNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if account.Status != 1 {
|
||||||
|
return nil, ErrAccountRestricted
|
||||||
|
}
|
||||||
|
snapshot := map[string]any{}
|
||||||
|
_ = json.Unmarshal(order.RuleSnapshot, &snapshot)
|
||||||
|
dispatchMode, _ := snapshot["dispatch_mode"].(string)
|
||||||
|
paymentStatus, _ := snapshot["manual_payment_status"].(string)
|
||||||
|
if dispatchMode == "manual" && paymentStatus != "paid" {
|
||||||
|
if account.AvailableAmount < order.TotalAmount {
|
||||||
|
return nil, ErrInsufficientBalance
|
||||||
|
}
|
||||||
|
holdAvailable, holdFrozen := account.AvailableAmount-order.TotalAmount, account.FrozenAmount+order.TotalAmount
|
||||||
|
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
|
||||||
|
version=version+1,updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=?`, holdAvailable,
|
||||||
|
holdFrozen, input.TenantID, account.ID, account.Version)
|
||||||
|
if updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||||
|
return nil, ErrConcurrentUpdate
|
||||||
|
}
|
||||||
|
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dMA", input.OrderID), "task_principal_hold", order.OrderNo, "available", 2,
|
||||||
|
order.TotalAmount, account.AvailableAmount, holdAvailable, "match:available", "会员确认手动订单扣除可用余额"); updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dMF", input.OrderID), "task_principal_hold", order.OrderNo, "frozen", 1,
|
||||||
|
order.TotalAmount, account.FrozenAmount, holdFrozen, "match:frozen", "会员确认手动订单冻结本金"); updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
snapshot["manual_payment_status"], snapshot["payment_confirmed_by"] = "paid", "member"
|
||||||
|
snapshot["payment_confirmed_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
rules, marshalErr := json.Marshal(snapshot)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return nil, marshalErr
|
||||||
|
}
|
||||||
|
if _, updateErr = tx.ExecContext(ctx, `UPDATE hg_trade_order SET rule_snapshot=?,message='会员已确认付款,正在提交订单',updated_at=UTC_TIMESTAMP(3)
|
||||||
|
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, rules, input.TenantID, input.OrderID, input.MemberID); updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
account.AvailableAmount, account.FrozenAmount, account.Version = holdAvailable, holdFrozen, account.Version+1
|
||||||
|
}
|
||||||
|
if account.FrozenAmount < order.TotalAmount {
|
||||||
|
return nil, ErrAccountRestricted
|
||||||
|
}
|
||||||
if order.DeadlineAt != "" {
|
if order.DeadlineAt != "" {
|
||||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
afterAvailable := account.AvailableAmount + order.TotalAmount
|
||||||
|
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||||
|
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
|
||||||
|
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAvailable, afterFrozen,
|
||||||
|
now, input.TenantID, account.ID, account.Version)
|
||||||
|
if updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||||
|
return nil, ErrConcurrentUpdate
|
||||||
|
}
|
||||||
|
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dEF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||||
|
order.TotalAmount, account.FrozenAmount, afterFrozen, "expire:frozen", "订单超时释放冻结本金"); updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
|
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dEA", input.OrderID), "task_principal_release", order.OrderNo, "available", 1,
|
||||||
|
order.TotalAmount, account.AvailableAmount, afterAvailable, "expire:available", "订单超时退回订单本金"); updateErr != nil {
|
||||||
|
return nil, updateErr
|
||||||
|
}
|
||||||
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
|
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
|
||||||
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
||||||
return nil, updateErr
|
return nil, updateErr
|
||||||
@@ -398,24 +705,64 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
|||||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if input.SubmissionNo == "" {
|
||||||
|
return nil, ErrOrderState
|
||||||
|
}
|
||||||
attachments, err := json.Marshal(input.AttachmentIDs)
|
attachments, err := json.Marshal(input.AttachmentIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := input.SettledAt.UTC()
|
||||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
if now.IsZero() {
|
||||||
|
now = time.Now().UTC()
|
||||||
|
}
|
||||||
|
principalAfter := account.AvailableAmount + order.TotalAmount
|
||||||
|
afterAmount := principalAfter + order.CommissionAmount
|
||||||
|
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||||
|
accountResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,
|
||||||
|
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAmount, afterFrozen,
|
||||||
|
order.CommissionAmount, now, input.TenantID, account.ID, account.Version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if affected, _ := accountResult.RowsAffected(); affected != 1 {
|
||||||
|
return nil, ErrOrderState
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dSF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||||
|
order.TotalAmount, account.FrozenAmount, afterFrozen, "settle:frozen", "任务完成释放冻结本金"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dSA", input.OrderID), "task_principal_return", order.OrderNo, "available", 1,
|
||||||
|
order.TotalAmount, account.AvailableAmount, principalAfter, "settle:available", "任务完成返回订单本金"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ledgerID, err := insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||||
|
fmt.Sprintf("WL%dSC", input.OrderID), "commission", input.SubmissionNo, "available", 1,
|
||||||
|
order.CommissionAmount, principalAfter, afterAmount, "settle:commission", "任务完成自动返佣")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
submissionResult, err := tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||||
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
||||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
|
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
|
||||||
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
|
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
|
||||||
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
|
VALUES(?,?,?,?,?,?,?,?,4,?,10000,0,?,'AUTO_SETTLED','会员提交后自动结算',2,?,?,0,0,?,?)`,
|
||||||
|
input.TenantID, input.SubmissionNo, input.OrderID, order.TaskID, input.MemberID, order.MemberLevelID,
|
||||||
|
input.EvidenceText, attachments, order.CommissionAmount, now, ledgerID, now, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDuplicate(err) {
|
if isDuplicate(err) {
|
||||||
return nil, ErrOrderState
|
return nil, ErrOrderState
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
|
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
|
||||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
|
return nil, ErrOrderState
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=4,message='订单完成,佣金已入账',
|
||||||
|
completed_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, now, now, input.TenantID, input.OrderID, input.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -425,7 +772,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusCompleted, SubmittedAt: now}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTaskLedger(ctx context.Context, tx *sqlx.Tx, tenantID, memberID, accountID, orderID int64,
|
||||||
|
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64,
|
||||||
|
idempotencySuffix, remark string) (int64, error) {
|
||||||
|
result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger
|
||||||
|
(tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount,
|
||||||
|
before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY',0,?,?,?,UTC_TIMESTAMP(3))`, tenantID, ledgerNo, accountID, memberID,
|
||||||
|
bizType, bizNo, entryType, direction, amount, beforeAmount, afterAmount,
|
||||||
|
fmt.Sprintf("task:%d:%s", orderID, idempotencySuffix), remark, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||||
@@ -433,7 +795,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
|
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
|
||||||
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1
|
AND tenant_id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||||
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestJSONInt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "number", value: float64(900), want: 900, ok: true},
|
||||||
|
{name: "numeric string", value: "1200", want: 1200, ok: true},
|
||||||
|
{name: "trimmed numeric string", value: " 900 ", want: 900, ok: true},
|
||||||
|
{name: "invalid string", value: "15 minutes", ok: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, ok := jsonInt(test.value)
|
||||||
|
if got != test.want || ok != test.ok {
|
||||||
|
t.Fatalf("jsonInt(%v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomDelaySeconds(t *testing.T) {
|
||||||
|
for index := 0; index < 100; index++ {
|
||||||
|
value := randomDelaySeconds(900, 1200)
|
||||||
|
if value < 900 || value > 1200 {
|
||||||
|
t.Fatalf("delay out of range: %d", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value := randomDelaySeconds(30, 30); value != 30 {
|
||||||
|
t.Fatalf("fixed delay = %d, want 30", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-29
@@ -16,12 +16,22 @@ type PaymentAccount struct {
|
|||||||
BankName string `db:"bank_name"`
|
BankName string `db:"bank_name"`
|
||||||
HolderCipher string `db:"holder_cipher"`
|
HolderCipher string `db:"holder_cipher"`
|
||||||
HolderMask string `db:"holder_mask"`
|
HolderMask string `db:"holder_mask"`
|
||||||
|
IdentityNoCipher string `db:"identity_no_cipher"`
|
||||||
|
IdentityNoHash string `db:"identity_no_hash"`
|
||||||
|
IdentityNoMask string `db:"identity_no_mask"`
|
||||||
MobileCipher string `db:"mobile_cipher"`
|
MobileCipher string `db:"mobile_cipher"`
|
||||||
MobileMask string `db:"mobile_mask"`
|
MobileMask string `db:"mobile_mask"`
|
||||||
AccountNoCipher string `db:"account_no_cipher"`
|
AccountNoCipher string `db:"account_no_cipher"`
|
||||||
AccountNoHash string `db:"account_no_hash"`
|
AccountNoHash string `db:"account_no_hash"`
|
||||||
AccountNoMask string `db:"account_no_mask"`
|
AccountNoMask string `db:"account_no_mask"`
|
||||||
|
DigitalRMBCodeCipher string `db:"digital_rmb_code_cipher"`
|
||||||
|
DigitalRMBCodeHash string `db:"digital_rmb_code_hash"`
|
||||||
|
DigitalRMBCodeMask string `db:"digital_rmb_code_mask"`
|
||||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||||
|
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
|
||||||
|
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
|
||||||
|
WechatQRURL string `db:"wechat_qr_url"`
|
||||||
|
AlipayQRURL string `db:"alipay_qr_url"`
|
||||||
VerifyStatus int `db:"verify_status"`
|
VerifyStatus int `db:"verify_status"`
|
||||||
IsDefault int `db:"is_default"`
|
IsDefault int `db:"is_default"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
@@ -31,18 +41,22 @@ type PaymentAccount struct {
|
|||||||
type PaymentAccountCreation struct {
|
type PaymentAccountCreation struct {
|
||||||
TenantID int64
|
TenantID int64
|
||||||
MemberID int64
|
MemberID int64
|
||||||
ExpectedTradePasswordHash string
|
|
||||||
AccountType int
|
AccountType int
|
||||||
BankCode string
|
BankCode string
|
||||||
BankName string
|
BankName string
|
||||||
HolderCipher string
|
HolderCipher string
|
||||||
HolderMask string
|
HolderMask string
|
||||||
MobileCipher string
|
IdentityNoCipher string
|
||||||
MobileMask string
|
IdentityNoHash string
|
||||||
|
IdentityNoMask string
|
||||||
AccountNoCipher string
|
AccountNoCipher string
|
||||||
AccountNoHash string
|
AccountNoHash string
|
||||||
AccountNoMask string
|
AccountNoMask string
|
||||||
QRAttachmentID int64
|
DigitalRMBCodeCipher string
|
||||||
|
DigitalRMBCodeHash string
|
||||||
|
DigitalRMBCodeMask string
|
||||||
|
WechatQRAttachmentID int64
|
||||||
|
AlipayQRAttachmentID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type WithdrawalChannel struct {
|
type WithdrawalChannel struct {
|
||||||
@@ -76,10 +90,14 @@ type WithdrawalCreation struct {
|
|||||||
ExpectedBankCode string
|
ExpectedBankCode string
|
||||||
ExpectedBankName string
|
ExpectedBankName string
|
||||||
ExpectedHolderCipher string
|
ExpectedHolderCipher string
|
||||||
ExpectedMobileCipher string
|
ExpectedIdentityNoCipher string
|
||||||
|
ExpectedIdentityNoHash string
|
||||||
ExpectedAccountNoCipher string
|
ExpectedAccountNoCipher string
|
||||||
ExpectedAccountNoHash string
|
ExpectedAccountNoHash string
|
||||||
ExpectedQRAttachmentID int64
|
ExpectedDigitalRMBCodeCipher string
|
||||||
|
ExpectedDigitalRMBCodeHash string
|
||||||
|
ExpectedWechatQRAttachmentID int64
|
||||||
|
ExpectedAlipayQRAttachmentID int64
|
||||||
Channel WithdrawalChannel
|
Channel WithdrawalChannel
|
||||||
RequestAmount int64
|
RequestAmount int64
|
||||||
FeeAmount int64
|
FeeAmount int64
|
||||||
@@ -126,8 +144,11 @@ type WithdrawalPage struct {
|
|||||||
|
|
||||||
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||||
items := make([]PaymentAccount, 0)
|
items := make([]PaymentAccount, 0)
|
||||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
|
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
|
||||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask,
|
||||||
|
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
|
||||||
|
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||||
|
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
|
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
|
||||||
@@ -135,6 +156,33 @@ func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) (
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaymentAccountDetails returns encrypted fields and their masked fallbacks for the authenticated owner.
|
||||||
|
func (s *Store) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||||
|
items := make([]PaymentAccount, 0)
|
||||||
|
err := s.db.SelectContext(ctx, &items, `SELECT a.id,a.account_type,a.bank_code,a.bank_name,
|
||||||
|
COALESCE(a.holder_cipher,'') AS holder_cipher,
|
||||||
|
COALESCE(a.holder_mask,'') AS holder_mask,
|
||||||
|
COALESCE(a.identity_no_cipher,'') AS identity_no_cipher,
|
||||||
|
COALESCE(a.identity_no_mask,'') AS identity_no_mask,
|
||||||
|
COALESCE(a.account_no_cipher,'') AS account_no_cipher,
|
||||||
|
COALESCE(a.account_no_mask,'') AS account_no_mask,
|
||||||
|
COALESCE(a.digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
|
||||||
|
COALESCE(a.digital_rmb_code_mask,'') AS digital_rmb_code_mask,
|
||||||
|
COALESCE(a.wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||||
|
COALESCE(a.alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
|
||||||
|
COALESCE(wechat.file_url,'') AS wechat_qr_url,
|
||||||
|
COALESCE(alipay.file_url,'') AS alipay_qr_url,
|
||||||
|
a.verify_status,a.is_default
|
||||||
|
FROM hg_app_member_payment_account a
|
||||||
|
LEFT JOIN hg_sys_attachment wechat ON wechat.id=a.wechat_qr_attachment_id
|
||||||
|
AND wechat.member_id=a.member_id AND wechat.app_id='olons_server' AND wechat.status=1
|
||||||
|
LEFT JOIN hg_sys_attachment alipay ON alipay.id=a.alipay_qr_attachment_id
|
||||||
|
AND alipay.member_id=a.member_id AND alipay.app_id='olons_server' AND alipay.status=1
|
||||||
|
WHERE a.tenant_id=? AND a.member_id=? AND a.deleted_at IS NULL
|
||||||
|
ORDER BY a.is_default ASC,a.id DESC`, tenantID, memberID)
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) {
|
func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) {
|
||||||
tx, err := s.db.BeginTxx(ctx, nil)
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,26 +191,23 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var memberID int64
|
var memberID int64
|
||||||
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||||
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrConcurrentUpdate
|
return nil, ErrConcurrentUpdate
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if input.QRAttachmentID > 0 {
|
|
||||||
var attachmentCount int
|
var attachmentCount int
|
||||||
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||||
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||||
input.TenantID, input.QRAttachmentID, input.MemberID)
|
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if attachmentCount != 1 {
|
if attachmentCount != 2 {
|
||||||
return nil, ErrAttachmentInvalid
|
return nil, ErrAttachmentInvalid
|
||||||
}
|
}
|
||||||
}
|
|
||||||
var accountCount int
|
var accountCount int
|
||||||
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
|
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
|
||||||
WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil {
|
WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil {
|
||||||
@@ -173,12 +218,17 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
|||||||
isDefault = 1
|
isDefault = 1
|
||||||
}
|
}
|
||||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
|
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
|
||||||
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher,
|
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||||
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
|
identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||||
|
digital_rmb_code_cipher,digital_rmb_code_hash,digital_rmb_code_mask,
|
||||||
|
wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status,
|
||||||
is_default,created_by,updated_by)
|
is_default,created_by,updated_by)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
|
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask,
|
||||||
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
|
input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask,
|
||||||
|
input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
|
||||||
|
input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
|
||||||
|
input.WechatQRAttachmentID, input.AlipayQRAttachmentID,
|
||||||
isDefault, input.MemberID, input.MemberID)
|
isDefault, input.MemberID, input.MemberID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDuplicate(err) {
|
if isDuplicate(err) {
|
||||||
@@ -194,9 +244,64 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
|
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
|
||||||
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
|
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
|
||||||
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
|
AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
|
||||||
VerifyStatus: 1, IsDefault: isDefault}, nil
|
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||||
|
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||||
|
VerifyStatus: 2, IsDefault: isDefault}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePaymentAccount(ctx context.Context, accountID int64, input PaymentAccountCreation) (*PaymentAccount, error) {
|
||||||
|
tx, err := s.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var isDefault int
|
||||||
|
err = tx.GetContext(ctx, &isDefault, `SELECT is_default FROM hg_app_member_payment_account
|
||||||
|
WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
||||||
|
accountID, input.TenantID, input.MemberID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrPaymentAccountInvalid
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var attachmentCount int
|
||||||
|
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||||
|
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||||
|
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if attachmentCount != 2 {
|
||||||
|
return nil, ErrAttachmentInvalid
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, `UPDATE hg_app_member_payment_account SET
|
||||||
|
bank_name=?,holder_cipher=?,holder_mask=?,identity_no_cipher=?,identity_no_hash=?,identity_no_mask=?,
|
||||||
|
account_no_cipher=?,account_no_hash=?,account_no_mask=?,digital_rmb_code_cipher=?,
|
||||||
|
digital_rmb_code_hash=?,digital_rmb_code_mask=?,wechat_qr_attachment_id=?,alipay_qr_attachment_id=?,
|
||||||
|
verify_status=2,updated_by=?,updated_at=NOW() WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL`,
|
||||||
|
input.BankName, input.HolderCipher, input.HolderMask, input.IdentityNoCipher, input.IdentityNoHash,
|
||||||
|
input.IdentityNoMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
|
||||||
|
input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
|
||||||
|
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID,
|
||||||
|
accountID, input.TenantID, input.MemberID)
|
||||||
|
if err != nil {
|
||||||
|
if isDuplicate(err) {
|
||||||
|
return nil, ErrPaymentAccountExists
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PaymentAccount{ID: accountID, AccountType: input.AccountType, BankCode: input.BankCode,
|
||||||
|
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
|
||||||
|
AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
|
||||||
|
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||||
|
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||||
|
VerifyStatus: 2, IsDefault: isDefault}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func nullableString(value string) any {
|
func nullableString(value string) any {
|
||||||
@@ -209,8 +314,13 @@ func nullableString(value string) any {
|
|||||||
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
|
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
|
||||||
var row PaymentAccount
|
var row PaymentAccount
|
||||||
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||||
|
COALESCE(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
|
||||||
|
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
|
||||||
|
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
|
||||||
|
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||||
|
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
|
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
|
||||||
@@ -289,8 +399,14 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
|||||||
|
|
||||||
var account PaymentAccount
|
var account PaymentAccount
|
||||||
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||||
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
|
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||||
|
COALESCE(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
|
||||||
|
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
|
||||||
|
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
|
||||||
|
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||||
|
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
|
||||||
|
verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
|
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
|
||||||
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
|
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -301,8 +417,12 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
|||||||
}
|
}
|
||||||
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
||||||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
||||||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
|
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
|
||||||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
|
account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
|
||||||
|
account.DigitalRMBCodeCipher != input.ExpectedDigitalRMBCodeCipher ||
|
||||||
|
account.DigitalRMBCodeHash != input.ExpectedDigitalRMBCodeHash ||
|
||||||
|
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
|
||||||
|
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
|
||||||
return nil, ErrConcurrentUpdate
|
return nil, ErrConcurrentUpdate
|
||||||
}
|
}
|
||||||
var channel WithdrawalChannel
|
var channel WithdrawalChannel
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package support
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrServiceNotFound = errors.New("customer service not found")
|
||||||
|
ErrServiceNotBound = errors.New("customer service IM account not bound")
|
||||||
|
ErrIMUnavailable = errors.New("IM service unavailable")
|
||||||
|
ErrRelationPending = errors.New("IM friend relationship is pending")
|
||||||
|
ErrRelationDenied = errors.New("IM friend relationship is not allowed")
|
||||||
|
)
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package support
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error)
|
||||||
|
MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error)
|
||||||
|
MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error)
|
||||||
|
SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IM interface {
|
||||||
|
Ready() bool
|
||||||
|
Config() config.IMConfig
|
||||||
|
CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error)
|
||||||
|
FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error)
|
||||||
|
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)
|
||||||
|
EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
repository Repository
|
||||||
|
im IM
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionRequest struct {
|
||||||
|
DeviceID string
|
||||||
|
Platform string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
IMUserID string `json:"im_user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
OnlineStatus string `json:"online_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
TenantCode string `json:"tenant_code"`
|
||||||
|
APIBaseURL string `json:"api_base_url"`
|
||||||
|
WebSocketURL string `json:"websocket_url"`
|
||||||
|
FileURL string `json:"file_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
CurrentUserID string `json:"current_user_id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Relationship string `json:"relationship"`
|
||||||
|
Agent Agent `json:"agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
|
||||||
|
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID int64, in SessionRequest) (*Session, error) {
|
||||||
|
if s == nil || s.repository == nil || s.im == nil || !s.im.Ready() || s.aead == nil {
|
||||||
|
return nil, ErrIMUnavailable
|
||||||
|
}
|
||||||
|
agent, err := s.repository.CustomerServiceByID(ctx, tenantID, serviceID)
|
||||||
|
if errors.Is(err, store.ErrResourceNotFound) {
|
||||||
|
return nil, ErrServiceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg := s.im.Config()
|
||||||
|
if strings.TrimSpace(agent.IMUserID) == "" || !strings.EqualFold(strings.TrimSpace(agent.IMTenantCode), strings.TrimSpace(cfg.TenantCode)) {
|
||||||
|
return nil, ErrServiceNotBound
|
||||||
|
}
|
||||||
|
identity, err := s.ensureIdentity(ctx, tenantID, memberID, cfg.TenantCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
platform := strings.TrimSpace(in.Platform)
|
||||||
|
if platform == "" {
|
||||||
|
platform = "web"
|
||||||
|
}
|
||||||
|
password, err := s.decryptCredential(identity.IMPasswordCipher)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt IM credential: %w", err)
|
||||||
|
}
|
||||||
|
imSession, err := s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
|
||||||
|
if imclient.IsAuthenticationFailed(err) {
|
||||||
|
identity, err = s.resetAndSaveCredential(ctx, identity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("repair IM credential: %w", err)
|
||||||
|
}
|
||||||
|
password, err = s.decryptCredential(identity.IMPasswordCipher)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt repaired IM credential: %w", err)
|
||||||
|
}
|
||||||
|
imSession, err = s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login IM session: %w", err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(imSession.UserID), strings.TrimSpace(identity.IMUserID)) {
|
||||||
|
return nil, ErrIMUnavailable
|
||||||
|
}
|
||||||
|
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if requestErr != nil {
|
||||||
|
profile, err = s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("refresh customer service relationship: %w", err)
|
||||||
|
}
|
||||||
|
relationship = strings.TrimSpace(profile.Relationship)
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
conversation, err := s.im.EnsureP2P(ctx, imSession.Token, agent.IMUserID,
|
||||||
|
fmt.Sprintf("p2p:%s:%s", identity.IMUserID, agent.IMUserID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure customer service conversation: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(conversation.Topic) == "" || (conversation.Relationship != "" && conversation.Relationship != "friend") || !conversation.CanSend {
|
||||||
|
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,
|
||||||
|
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)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
|
||||||
|
identity, err := s.repository.MemberIMIdentity(ctx, tenantID, memberID)
|
||||||
|
if err == nil {
|
||||||
|
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || identity.IMUserID == "" || identity.IMUsername == "" {
|
||||||
|
return nil, ErrIMUnavailable
|
||||||
|
}
|
||||||
|
if identity.IMPasswordCipher != "" {
|
||||||
|
return identity, nil
|
||||||
|
}
|
||||||
|
return s.resetAndSaveCredential(ctx, identity)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, store.ErrResourceNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
username := memberIMUsername(tenantID, memberID, imTenantCode)
|
||||||
|
nickname := strings.TrimSpace(member.Username)
|
||||||
|
if nickname == "" {
|
||||||
|
nickname = strings.TrimSpace(member.MemberNo)
|
||||||
|
}
|
||||||
|
if nickname == "" {
|
||||||
|
nickname = "会员"
|
||||||
|
}
|
||||||
|
password, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
created, err := s.im.CreateUser(ctx, username, nickname, password)
|
||||||
|
if err != nil && imclient.IsConflict(err) {
|
||||||
|
created, err = s.im.FindUserByUsername(ctx, username)
|
||||||
|
if err == nil {
|
||||||
|
err = s.im.ResetPassword(ctx, string(created.UserID), password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create IM identity: %w", err)
|
||||||
|
}
|
||||||
|
issued, err := s.im.LoginBasic(ctx, created.Username, password, "provision", "server")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login provisioned IM identity: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := s.encryptCredential(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
identity = &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode,
|
||||||
|
IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext}
|
||||||
|
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resetAndSaveCredential(ctx context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||||
|
password, err := randomPassword()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = s.im.ResetPassword(ctx, identity.IMUserID, password); err != nil {
|
||||||
|
return nil, fmt.Errorf("reset IM credential: %w", err)
|
||||||
|
}
|
||||||
|
identity.IMPasswordCipher, err = s.encryptCredential(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialAEAD(cfg config.AuthConfig) cipher.AEAD {
|
||||||
|
var key []byte
|
||||||
|
if masterKey := strings.TrimSpace(cfg.FinanceMasterKey); len([]byte(masterKey)) >= 32 {
|
||||||
|
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||||
|
_, _ = mac.Write([]byte("olons-im:credential:v1"))
|
||||||
|
key = mac.Sum(nil)
|
||||||
|
} else {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
||||||
|
if err != nil {
|
||||||
|
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
||||||
|
}
|
||||||
|
if err != nil || len(decoded) != 32 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key = decoded
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return aead
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) encryptCredential(value string) (string, error) {
|
||||||
|
nonce := make([]byte, s.aead.NonceSize())
|
||||||
|
if _, err := cryptorand.Read(nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
||||||
|
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) decryptCredential(value string) (string, error) {
|
||||||
|
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
|
||||||
|
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||||
|
return "", errors.New("encrypted IM credential is invalid")
|
||||||
|
}
|
||||||
|
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The IM service limits usernames to ten lowercase letters or digits. A short
|
||||||
|
// stable hash keeps provisioning idempotent without leaking the business ID.
|
||||||
|
func memberIMUsername(tenantID, memberID int64, imTenantCode string) string {
|
||||||
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.ToLower(strings.TrimSpace(imTenantCode)), tenantID, memberID)))
|
||||||
|
return "om" + fmt.Sprintf("%x", sum[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPassword() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := cryptorand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func customerServiceStatus(status int) string {
|
||||||
|
switch status {
|
||||||
|
case 1:
|
||||||
|
return "online"
|
||||||
|
case 3:
|
||||||
|
return "busy"
|
||||||
|
default:
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package support
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.118im.com/im_team/olons_server/internal/config"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||||
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
agent *store.CustomerService
|
||||||
|
identity *store.MemberIMIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return r.identity, nil
|
||||||
|
}
|
||||||
|
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
|
||||||
|
return nil, store.ErrResourceNotFound
|
||||||
|
}
|
||||||
|
func (r *repositoryStub) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||||
|
return r.identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type imStub struct {
|
||||||
|
friendRequests int
|
||||||
|
loginAttempts int
|
||||||
|
resetPasswords int
|
||||||
|
loginError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *imStub) Ready() bool { return true }
|
||||||
|
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) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (i *imStub) LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error) {
|
||||||
|
i.loginAttempts++
|
||||||
|
if i.loginError != nil {
|
||||||
|
err := i.loginError
|
||||||
|
i.loginError = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &imclient.Session{UserID: "usr_member", Token: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
}
|
||||||
|
func (i *imStub) ResetPassword(context.Context, string, string) error {
|
||||||
|
i.resetPasswords++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserProfile, error) {
|
||||||
|
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) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
|
||||||
|
return &imclient.Conversation{Topic: "p2p-topic", Relationship: "friend", CanSend: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSessionAutoAcceptsAndReturnsTopic(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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSessionRepairsDriftedIMCredential(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{loginError: &imclient.Error{Status: 401, Reason: "authentication failed"}}
|
||||||
|
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
|
||||||
|
repository.identity.IMPasswordCipher, _ = service.encryptCredential("stale-password")
|
||||||
|
|
||||||
|
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if session.Topic != "p2p-topic" {
|
||||||
|
t.Fatalf("unexpected session: %+v", session)
|
||||||
|
}
|
||||||
|
if im.loginAttempts != 2 || im.resetPasswords != 1 {
|
||||||
|
t.Fatalf("login attempts=%d reset passwords=%d, want 2 and 1", im.loginAttempts, im.resetPasswords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberIMUsernameMatchesIMConstraint(t *testing.T) {
|
||||||
|
username := memberIMUsername(0, 9223372036854775807, "allrich")
|
||||||
|
if len(username) != 10 {
|
||||||
|
t.Fatalf("username length = %d, want 10: %s", len(username), username)
|
||||||
|
}
|
||||||
|
for _, ch := range username {
|
||||||
|
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') {
|
||||||
|
t.Fatalf("invalid username %q", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,6 +27,7 @@ type Repository interface {
|
|||||||
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
||||||
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
|
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
|
||||||
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
|
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
|
||||||
|
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error
|
||||||
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
|
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
|
||||||
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
||||||
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
||||||
@@ -75,6 +77,9 @@ type Order struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText string `json:"status_text"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
DispatchMode string `json:"dispatch_mode,omitempty"`
|
||||||
|
ScheduledMatchAt string `json:"scheduled_match_at,omitempty"`
|
||||||
|
DispatchDelay int `json:"dispatch_delay_seconds,omitempty"`
|
||||||
DeadlineAt string `json:"deadline_at"`
|
DeadlineAt string `json:"deadline_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
@@ -150,6 +155,9 @@ type SubmitResult struct {
|
|||||||
type taskPolicy struct {
|
type taskPolicy struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
MinimumAmount int64
|
MinimumAmount int64
|
||||||
|
AutoDelay int
|
||||||
|
AutoMinAmount int64
|
||||||
|
AutoMaxAmount int64
|
||||||
Start string
|
Start string
|
||||||
End string
|
End string
|
||||||
Deadline time.Duration
|
Deadline time.Duration
|
||||||
@@ -198,7 +206,7 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
|
|||||||
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
||||||
case row.WalletStatus != 1:
|
case row.WalletStatus != 1:
|
||||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||||
case row.AvailableAmount < policy.MinimumAmount:
|
case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount):
|
||||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
||||||
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
||||||
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
||||||
@@ -230,14 +238,39 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
|
|||||||
}
|
}
|
||||||
now := s.now().UTC()
|
now := s.now().UTC()
|
||||||
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
|
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
|
||||||
HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)})
|
HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay,
|
||||||
|
DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := orderFromStore(*row, false)
|
result := orderFromStore(*row, false)
|
||||||
|
s.scheduleAutoMatch(tenantID, memberID, row.ID, row.RuleSnapshot, policy)
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) scheduleAutoMatch(tenantID, memberID, orderID int64, raw json.RawMessage, policy taskPolicy) {
|
||||||
|
var snapshot map[string]any
|
||||||
|
if json.Unmarshal(raw, &snapshot) != nil || snapshot["dispatch_mode"] != "auto" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchAt, err := time.Parse(time.RFC3339, fmt.Sprint(snapshot["scheduled_match_at"]))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delay := time.Until(matchAt)
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
<-timer.C
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
|
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
|
||||||
if page == 0 {
|
if page == 0 {
|
||||||
page = 1
|
page = 1
|
||||||
@@ -274,6 +307,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if row.OrderStatus == 1 {
|
||||||
|
policy, policyErr := s.policy(ctx, tenantID)
|
||||||
|
if policyErr != nil {
|
||||||
|
return nil, policyErr
|
||||||
|
}
|
||||||
|
if policy.Enabled {
|
||||||
|
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if policy.Enabled && err == nil {
|
||||||
|
row, err = s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
|
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -300,17 +351,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
|
|||||||
}
|
}
|
||||||
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
||||||
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
|
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
|
||||||
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) {
|
!validUniqueIDs(input.EvidenceAttachmentIDs) {
|
||||||
return nil, ErrEvidenceInvalid
|
return nil, ErrEvidenceInvalid
|
||||||
}
|
}
|
||||||
now := s.now().UTC()
|
now := s.now().UTC()
|
||||||
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
||||||
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
|
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
|
||||||
AttachmentIDs: input.EvidenceAttachmentIDs})
|
AttachmentIDs: input.EvidenceAttachmentIDs, LedgerNo: newNumber("WL", now),
|
||||||
|
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
|
||||||
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +383,9 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
|
|||||||
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
||||||
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
||||||
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
||||||
|
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
|
||||||
|
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
|
||||||
|
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
|
||||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +396,12 @@ func orderFromStore(row store.TaskOrder, detail bool) Order {
|
|||||||
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
|
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
|
||||||
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
|
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
|
||||||
DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||||
|
var snapshot map[string]any
|
||||||
|
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
|
||||||
|
result.DispatchMode, _ = snapshot["dispatch_mode"].(string)
|
||||||
|
result.ScheduledMatchAt, _ = snapshot["scheduled_match_at"].(string)
|
||||||
|
result.DispatchDelay = intValue(snapshot["dispatch_delay_seconds"])
|
||||||
|
}
|
||||||
if !detail {
|
if !detail {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -382,7 +443,11 @@ func orderTimeline(row store.TaskOrder) []TimelineEntry {
|
|||||||
func allowedActions(row store.TaskOrder) []string {
|
func allowedActions(row store.TaskOrder) []string {
|
||||||
switch row.OrderStatus {
|
switch row.OrderStatus {
|
||||||
case 2:
|
case 2:
|
||||||
|
var snapshot map[string]any
|
||||||
|
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
|
||||||
return []string{"submit", "contact_support"}
|
return []string{"submit", "contact_support"}
|
||||||
|
}
|
||||||
|
return []string{"contact_support"}
|
||||||
case 3, 5, 6, 8:
|
case 3, 5, 6, 8:
|
||||||
return []string{"contact_support"}
|
return []string{"contact_support"}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.118im.com/im_team/im_workbench/internal/store"
|
"git.118im.com/im_team/olons_server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
@@ -29,9 +30,20 @@ func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (
|
|||||||
}
|
}
|
||||||
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
||||||
f.claimed = input
|
f.claimed = input
|
||||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY",
|
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
|
||||||
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
|
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error {
|
||||||
|
if f.order != nil && f.order.OrderStatus == 1 {
|
||||||
|
f.order.OrderStatus = 2
|
||||||
|
f.order.ProductID = 7
|
||||||
|
f.order.ProductName = "自动匹配商品"
|
||||||
|
f.order.Quantity = 1
|
||||||
|
f.order.TotalAmount = 10000
|
||||||
|
f.order.CommissionAmount = 100
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
|
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
|
||||||
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
|
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
|
||||||
}
|
}
|
||||||
@@ -43,7 +55,7 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
|
|||||||
}
|
}
|
||||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||||
f.submitted = input
|
f.submitted = input
|
||||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
|
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4,
|
||||||
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
|
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
|
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
|
||||||
@@ -52,7 +64,7 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) (
|
|||||||
|
|
||||||
func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
||||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||||
eligibility: &store.TaskEligibility{MemberStatus: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||||
|
|
||||||
@@ -65,6 +77,36 @@ func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEligibilityAllowsManualDispatchBelowMinimumBalance(t *testing.T) {
|
||||||
|
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||||
|
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||||
|
service := New(repository)
|
||||||
|
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||||
|
|
||||||
|
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !result.Eligible {
|
||||||
|
t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEligibilityRejectsAutoDispatchWithZeroBalanceWhenMinimumIsZero(t *testing.T) {
|
||||||
|
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||||
|
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||||
|
service := New(repository)
|
||||||
|
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||||
|
|
||||||
|
result, err := service.Eligibility(context.Background(), 0, 10, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" {
|
||||||
|
t.Fatalf("zero balance must not allow automatic claiming: %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||||
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
@@ -75,7 +117,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClaimBuildsPendingOrder(t *testing.T) {
|
func TestClaimBuildsMatchingOrder(t *testing.T) {
|
||||||
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||||
@@ -85,29 +127,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Status != "pending" || repository.claimed.HallID != 2 ||
|
if result.Status != "matching" || repository.claimed.HallID != 2 ||
|
||||||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
|
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
|
||||||
repository := &fakeRepository{}
|
repository := &fakeRepository{}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
|
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) {
|
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
|
||||||
t.Fatalf("expected evidence error, got %v", err)
|
|
||||||
}
|
|
||||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
|
if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" {
|
||||||
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubmitStillValidatesOptionalEvidence(t *testing.T) {
|
||||||
|
service := New(&fakeRepository{})
|
||||||
|
tooLong := strings.Repeat("字", 2001)
|
||||||
|
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) {
|
||||||
|
t.Fatalf("expected evidence error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||||
service := New(&fakeRepository{})
|
service := New(&fakeRepository{})
|
||||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class DeploymentContractTest(unittest.TestCase):
|
|||||||
dockerfile = (ROOT / "Dockerfile").read_text()
|
dockerfile = (ROOT / "Dockerfile").read_text()
|
||||||
|
|
||||||
self.assertIn("RUN pnpm run build", dockerfile)
|
self.assertIn("RUN pnpm run build", dockerfile)
|
||||||
self.assertIn("VITE_PUBLIC_PATH=/admin/", dockerfile)
|
self.assertIn("ENV VITE_PUBLIC_PATH=/", dockerfile)
|
||||||
|
self.assertNotIn("VITE_PUBLIC_PATH=/admin/", dockerfile)
|
||||||
self.assertIn("COPY --from=web-builder /web/dist ./web/dist", dockerfile)
|
self.assertIn("COPY --from=web-builder /web/dist ./web/dist", dockerfile)
|
||||||
self.assertIn("COPY --from=web-builder /web/dist /app/web/dist", dockerfile)
|
self.assertIn("COPY --from=web-builder /web/dist /app/web/dist", dockerfile)
|
||||||
self.assertIn("EXPOSE 6061", dockerfile)
|
self.assertIn("EXPOSE 6061", dockerfile)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@vicons/ionicons5": "^0.12.0",
|
"@vicons/ionicons5": "^0.12.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
|
"tinode-sdk": "^0.25.4",
|
||||||
"vue": "^3.4.38",
|
"vue": "^3.4.38",
|
||||||
"vue-router": "^4.4.3"
|
"vue-router": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+17
@@ -17,6 +17,9 @@ importers:
|
|||||||
naive-ui:
|
naive-ui:
|
||||||
specifier: ^2.44.1
|
specifier: ^2.44.1
|
||||||
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
|
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
|
||||||
|
tinode-sdk:
|
||||||
|
specifier: ^0.25.4
|
||||||
|
version: 0.25.4
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.4.38
|
specifier: ^3.4.38
|
||||||
version: 3.4.38(typescript@5.5.4)
|
version: 3.4.38(typescript@5.5.4)
|
||||||
@@ -258,46 +261,55 @@ packages:
|
|||||||
resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==}
|
resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.21.2':
|
'@rollup/rollup-linux-arm-musleabihf@4.21.2':
|
||||||
resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==}
|
resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.21.2':
|
'@rollup/rollup-linux-arm64-gnu@4.21.2':
|
||||||
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
|
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.21.2':
|
'@rollup/rollup-linux-arm64-musl@4.21.2':
|
||||||
resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==}
|
resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-powerpc64le-gnu@4.21.2':
|
'@rollup/rollup-linux-powerpc64le-gnu@4.21.2':
|
||||||
resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==}
|
resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.21.2':
|
'@rollup/rollup-linux-riscv64-gnu@4.21.2':
|
||||||
resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==}
|
resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.21.2':
|
'@rollup/rollup-linux-s390x-gnu@4.21.2':
|
||||||
resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==}
|
resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.21.2':
|
'@rollup/rollup-linux-x64-gnu@4.21.2':
|
||||||
resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==}
|
resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.21.2':
|
'@rollup/rollup-linux-x64-musl@4.21.2':
|
||||||
resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==}
|
resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.21.2':
|
'@rollup/rollup-win32-arm64-msvc@4.21.2':
|
||||||
resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==}
|
resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==}
|
||||||
@@ -574,6 +586,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tinode-sdk@0.25.4:
|
||||||
|
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
|
||||||
|
|
||||||
to-fast-properties@2.0.0:
|
to-fast-properties@2.0.0:
|
||||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1151,6 +1166,8 @@ snapshots:
|
|||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
tinode-sdk@0.25.4: {}
|
||||||
|
|
||||||
to-fast-properties@2.0.0: {}
|
to-fast-properties@2.0.0: {}
|
||||||
|
|
||||||
treemate@0.3.11: {}
|
treemate@0.3.11: {}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
|||||||
|
|
||||||
const TOKEN_KEY = 'olons_web_session';
|
const TOKEN_KEY = 'olons_web_session';
|
||||||
const DEVICE_KEY = 'olons_web_device';
|
const DEVICE_KEY = 'olons_web_device';
|
||||||
|
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
|
||||||
|
|
||||||
const reasonMessages: Record<string, string> = {
|
const reasonMessages: Record<string, string> = {
|
||||||
AUTH_REQUIRED: '登录已失效,请重新登录',
|
AUTH_REQUIRED: '登录已失效,请重新登录',
|
||||||
FORBIDDEN: '当前账户无权执行此操作',
|
FORBIDDEN: '当前账户无权执行此操作',
|
||||||
INVALID_CREDENTIALS: '手机号或密码错误',
|
INVALID_CREDENTIALS: '手机号或密码错误',
|
||||||
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
||||||
|
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
|
||||||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||||
TASK_DISABLED: '领取功能暂未开放',
|
TASK_DISABLED: '领取功能暂未开放',
|
||||||
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
||||||
@@ -19,6 +21,9 @@ const reasonMessages: Record<string, string> = {
|
|||||||
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
||||||
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
||||||
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
||||||
|
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
|
||||||
|
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
|
||||||
|
IMAGE_INVALID: '图片格式或大小不符合要求',
|
||||||
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
||||||
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
||||||
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
||||||
@@ -28,6 +33,11 @@ const reasonMessages: Record<string, string> = {
|
|||||||
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
||||||
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
||||||
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
||||||
|
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
|
||||||
|
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
|
||||||
|
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
|
||||||
|
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
|
||||||
|
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
|
||||||
INTERNAL: '服务异常,请稍后重试',
|
INTERNAL: '服务异常,请稍后重试',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,18 +140,32 @@ function getDeviceID() {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deviceID() {
|
||||||
|
return getDeviceID();
|
||||||
|
}
|
||||||
|
|
||||||
export function saveSession(token: string) {
|
export function saveSession(token: string) {
|
||||||
sessionStorage.setItem(TOKEN_KEY, token);
|
sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
|
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSession() {
|
export function clearSession() {
|
||||||
sessionStorage.removeItem(TOKEN_KEY);
|
sessionStorage.removeItem(TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasSession() {
|
export function hasSession() {
|
||||||
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowLoginNotice() {
|
||||||
|
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeLoginNotice() {
|
||||||
|
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
|
||||||
|
}
|
||||||
|
|
||||||
export function idempotencyKey() {
|
export function idempotencyKey() {
|
||||||
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png';
|
|||||||
import processMaterial from './assets/process-material.png';
|
import processMaterial from './assets/process-material.png';
|
||||||
import processTime from './assets/process-time.png';
|
import processTime from './assets/process-time.png';
|
||||||
import processReturn from './assets/process-return.png';
|
import processReturn from './assets/process-return.png';
|
||||||
import chatVoice from './assets/chat-voice.png';
|
|
||||||
import chatMore from './assets/chat-more.png';
|
import chatMore from './assets/chat-more.png';
|
||||||
import companyPlatform from './assets/company-platform.png';
|
import companyPlatform from './assets/company-platform.png';
|
||||||
import companyRules from './assets/company-rules.png';
|
import companyRules from './assets/company-rules.png';
|
||||||
@@ -108,7 +107,6 @@ export const mobileAssets = {
|
|||||||
processMaterial,
|
processMaterial,
|
||||||
processTime,
|
processTime,
|
||||||
processReturn,
|
processReturn,
|
||||||
chatVoice,
|
|
||||||
chatMore,
|
chatMore,
|
||||||
companyPlatform,
|
companyPlatform,
|
||||||
companyRules,
|
companyRules,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="announcement-fade">
|
||||||
|
<div v-if="show" class="m-announcement-mask" role="presentation">
|
||||||
|
<section
|
||||||
|
class="m-announcement-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="announcement-title"
|
||||||
|
aria-describedby="announcement-content"
|
||||||
|
>
|
||||||
|
<h2 id="announcement-title" class="m-announcement-sr-title">
|
||||||
|
{{ notice?.title || '公告通知' }}
|
||||||
|
</h2>
|
||||||
|
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||||
|
<div class="m-announcement-body">
|
||||||
|
<div id="announcement-content" class="m-announcement-content">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, watch } from 'vue';
|
||||||
|
import announcementBoard from '../assets/announcement-popup@2x.png';
|
||||||
|
|
||||||
|
export interface AnnouncementNotice {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
body_html?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
notice?: AnnouncementNotice | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-board {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-body {
|
||||||
|
position: absolute;
|
||||||
|
inset: 31.2% 16.7% 13.6%;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: #282828;
|
||||||
|
font-size: clamp(14px, 4.25vw, 16px);
|
||||||
|
line-height: 1.42;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-line;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-body button {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 41px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(90deg, #ff972c 0%, #f45119 100%);
|
||||||
|
box-shadow: 0 5px 12px rgba(244, 81, 25, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-announcement-sr-title {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
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,254 @@
|
|||||||
|
import { Drafty, Tinode } from 'tinode-sdk';
|
||||||
|
|
||||||
|
export interface CustomerServiceSession {
|
||||||
|
tenant_code: string;
|
||||||
|
api_base_url: string;
|
||||||
|
file_url: string;
|
||||||
|
websocket_url: string;
|
||||||
|
api_key: string;
|
||||||
|
token: string;
|
||||||
|
current_user_id: string;
|
||||||
|
topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
seq: number;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
sent_at: string;
|
||||||
|
mine: boolean;
|
||||||
|
sending: boolean;
|
||||||
|
failed: boolean;
|
||||||
|
type: 'text' | 'image' | 'video';
|
||||||
|
media_url?: string;
|
||||||
|
preview_url?: string;
|
||||||
|
file_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerServiceChat {
|
||||||
|
private tinode: any;
|
||||||
|
private topic: any;
|
||||||
|
private currentUserID: string;
|
||||||
|
private onMessages: (messages: ChatMessage[]) => void;
|
||||||
|
private session: CustomerServiceSession;
|
||||||
|
|
||||||
|
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
|
||||||
|
const endpoint = new URL(session.websocket_url);
|
||||||
|
this.session = session;
|
||||||
|
this.currentUserID = session.current_user_id;
|
||||||
|
this.onMessages = onMessages;
|
||||||
|
this.tinode = new Tinode({
|
||||||
|
appName: 'Olons Web',
|
||||||
|
host: endpoint.host,
|
||||||
|
apiKey: session.api_key,
|
||||||
|
transport: 'ws',
|
||||||
|
secure: endpoint.protocol === 'wss:',
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
this.installTenantPatch(session.tenant_code);
|
||||||
|
this.topic = this.tinode.getTopic(session.topic);
|
||||||
|
this.topic.onData = () => this.refreshMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(token: string) {
|
||||||
|
await this.tinode.connect();
|
||||||
|
const login = await this.tinode.loginToken(token);
|
||||||
|
if (login?.code >= 300) throw new Error('客服会话登录失败');
|
||||||
|
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
|
||||||
|
const subscribed = await this.topic.subscribe(query);
|
||||||
|
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
|
||||||
|
this.refreshMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(text: string) {
|
||||||
|
const value = text.trim();
|
||||||
|
if (!value) return;
|
||||||
|
const message = this.topic.createMessage(value, false);
|
||||||
|
message.head = Object.assign(message.head || {}, { 'x-im-client-mid': clientMID() });
|
||||||
|
const result = await this.topic.publishDraft(message, Promise.resolve());
|
||||||
|
if (!result || result.code >= 300 || message._failed || message._fatal) {
|
||||||
|
throw new Error(result?.text || '消息发送失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMedia(file: File, type: 'image' | 'video', onProgress?: (progress: number) => void) {
|
||||||
|
const dimensions = await mediaDimensions(file, type);
|
||||||
|
const ref = await this.upload(file, onProgress);
|
||||||
|
const descriptor = {
|
||||||
|
mime: file.type,
|
||||||
|
// Keep the server-issued relative reference. Tinode only includes
|
||||||
|
// relative references in extra.attachments, which lets the server bind
|
||||||
|
// the uploaded file to this message and authorize the recipient.
|
||||||
|
refurl: ref,
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
duration: dimensions.duration,
|
||||||
|
};
|
||||||
|
const content = type === 'image'
|
||||||
|
? Drafty.appendImage(null, descriptor)
|
||||||
|
: Drafty.insertVideo(null, 0, descriptor);
|
||||||
|
const message = this.topic.createMessage(content, false);
|
||||||
|
message.head = Object.assign(message.head || {}, {
|
||||||
|
'x-im-client-mid': clientMID(),
|
||||||
|
'x-im-type': type,
|
||||||
|
});
|
||||||
|
const result = await this.topic.publishDraft(message, Promise.resolve());
|
||||||
|
if (!result || result.code >= 300 || message._failed || message._fatal) {
|
||||||
|
throw new Error(result?.text || `${type === 'image' ? '照片' : '视频'}发送失败,请重试`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
try {
|
||||||
|
this.topic?.leave?.(false);
|
||||||
|
} finally {
|
||||||
|
this.tinode?.disconnect?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private installTenantPatch(tenantCode: string) {
|
||||||
|
const connection = this.tinode._connection;
|
||||||
|
if (!connection || connection.__olonsTenantPatched) return;
|
||||||
|
const original = connection.sendText.bind(connection);
|
||||||
|
connection.sendText = (packet: unknown) => {
|
||||||
|
if (typeof packet === 'string' && packet.includes('"hi":')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(packet);
|
||||||
|
if (parsed.hi) {
|
||||||
|
parsed.hi.tenant = tenantCode;
|
||||||
|
packet = JSON.stringify(parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Preserve the original packet when it is not JSON.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return original(packet);
|
||||||
|
};
|
||||||
|
connection.__olonsTenantPatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshMessages() {
|
||||||
|
const rows: ChatMessage[] = [];
|
||||||
|
this.topic.messages((message: any) => {
|
||||||
|
const parsed = messageContent(message?.content, (ref) => this.mediaURL(ref));
|
||||||
|
if (!parsed.text && !parsed.media_url) return;
|
||||||
|
rows.push({
|
||||||
|
seq: Number(message.seq || 0),
|
||||||
|
from: String(message.from || ''),
|
||||||
|
text: parsed.text,
|
||||||
|
sent_at: message.ts ? new Date(message.ts).toISOString() : '',
|
||||||
|
mine: message.from === this.currentUserID,
|
||||||
|
sending: Boolean(message._sending),
|
||||||
|
failed: Boolean(message._failed || message._fatal),
|
||||||
|
type: parsed.type,
|
||||||
|
media_url: parsed.media_url,
|
||||||
|
preview_url: parsed.preview_url,
|
||||||
|
file_name: parsed.file_name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.onMessages(rows.sort((a, b) => a.seq - b.seq));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upload(file: File, onProgress?: (progress: number) => void) {
|
||||||
|
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url);
|
||||||
|
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 });
|
||||||
|
} catch {
|
||||||
|
throw new Error('媒体上传失败,请检查网络后重试');
|
||||||
|
}
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error('媒体上传响应格式不正确');
|
||||||
|
}
|
||||||
|
const ref = String(payload?.ctrl?.params?.url || '');
|
||||||
|
if (!response.ok || !ref) {
|
||||||
|
throw new Error(payload?.ctrl?.text || '媒体上传失败');
|
||||||
|
}
|
||||||
|
onProgress?.(1);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mediaURL(ref?: string) {
|
||||||
|
if (!ref) return '';
|
||||||
|
const apiBase = new URL(this.session.api_base_url);
|
||||||
|
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;
|
||||||
|
const authorized = this.tinode.authorizeURL(signable);
|
||||||
|
return new URL(authorized, this.session.api_base_url).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageContent(content: unknown, mediaURL: (ref?: string) => string) {
|
||||||
|
if (typeof content === 'string') return { text: content, type: 'text' as const };
|
||||||
|
if (content && typeof content === 'object') {
|
||||||
|
const drafty = content as { txt?: unknown; ent?: Array<{ tp?: string; data?: Record<string, unknown> }> };
|
||||||
|
const media = drafty.ent?.find((entity) => entity?.tp === 'IM' || entity?.tp === 'VD');
|
||||||
|
if (media?.data) {
|
||||||
|
const type = media.tp === 'VD' ? 'video' : 'image';
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
type,
|
||||||
|
media_url: mediaURL(String(media.data.ref || '')),
|
||||||
|
preview_url: mediaURL(String(media.data.preref || '')),
|
||||||
|
file_name: String(media.data.name || ''),
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return { text: String(drafty.txt || ''), type: 'text' as const };
|
||||||
|
}
|
||||||
|
return { text: '', type: 'text' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaDimensions(file: File, type: 'image' | 'video') {
|
||||||
|
return new Promise<{ width: number; height: number; duration: number }>((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const cleanup = () => URL.revokeObjectURL(url);
|
||||||
|
if (type === 'image') {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ width: image.naturalWidth, height: image.naturalHeight, duration: 0 });
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('无法读取所选照片'));
|
||||||
|
};
|
||||||
|
image.src = url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ width: video.videoWidth, height: video.videoHeight, duration: Math.round(video.duration * 1000) });
|
||||||
|
};
|
||||||
|
video.onerror = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('无法读取所选视频'));
|
||||||
|
};
|
||||||
|
video.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientMID() {
|
||||||
|
return crypto.randomUUID?.() || `mid_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
}
|
||||||
+646
-24
@@ -375,12 +375,45 @@ body {
|
|||||||
background: var(--m-bg);
|
background: var(--m-bg);
|
||||||
}
|
}
|
||||||
.m-home-hero {
|
.m-home-hero {
|
||||||
|
position: relative;
|
||||||
min-height: 305px;
|
min-height: 305px;
|
||||||
padding: calc(34px + env(safe-area-inset-top)) 30px 26px;
|
padding: calc(34px + env(safe-area-inset-top)) 30px 26px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: #1b1711;
|
background-color: #1b1711;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
transition: background-image 0.35s ease;
|
||||||
|
}
|
||||||
|
.m-home-banner-link {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-home-hero > :not(.m-home-banner-link) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.m-home-banner-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 13px;
|
||||||
|
}
|
||||||
|
.m-home-banner-dots button {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
transition: width 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.m-home-banner-dots button.active {
|
||||||
|
width: 20px;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
.m-search {
|
.m-search {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -1008,6 +1041,108 @@ body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.m-order-submit-panel {
|
||||||
|
padding: 17px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff8ea;
|
||||||
|
box-shadow: 0 4px 18px rgba(73, 54, 31, 0.07);
|
||||||
|
}
|
||||||
|
.m-order-submit-panel strong {
|
||||||
|
color: #6f3f12;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.m-order-submit-panel p {
|
||||||
|
margin: 7px 0 15px;
|
||||||
|
color: #8b765e;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.m-order-submit-panel > button,
|
||||||
|
.m-order-submit-actions button:last-child {
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #ffa32b, #f15b20);
|
||||||
|
}
|
||||||
|
.m-order-submit-panel > button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.m-order-recharge-link {
|
||||||
|
min-height: 46px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, #ffa32b, #f15b20);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.m-order-submit-panel button:disabled,
|
||||||
|
.m-order-submit-actions button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.m-order-submit-panel > button:disabled {
|
||||||
|
color: #999;
|
||||||
|
background: #e9e9e9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm {
|
||||||
|
width: min(90vw, 430px);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 16px 50px rgba(70, 40, 10, 0.2);
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm h2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 22px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm > p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 25px 18px;
|
||||||
|
color: #8a8a8a;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm dl {
|
||||||
|
margin: 0 20px 22px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #faf8f5;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm dl > div {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm dt {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.m-order-submit-confirm dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.m-order-submit-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-top: 1px solid #f0ebe4;
|
||||||
|
}
|
||||||
|
.m-order-submit-actions button {
|
||||||
|
min-height: 54px;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
.m-btn-link {
|
.m-btn-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -1301,18 +1436,24 @@ body {
|
|||||||
background: #c86d45;
|
background: #c86d45;
|
||||||
}
|
}
|
||||||
.m-chat {
|
.m-chat {
|
||||||
min-height: 100dvh;
|
height: 100dvh;
|
||||||
background: #f4f5f6;
|
background: #f4f5f6;
|
||||||
padding-bottom: 80px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.m-chat .m-page-header {
|
.m-chat .m-page-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
padding-inline: 20px;
|
padding-inline: 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
.m-chat-time {
|
.m-chat-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #92969b;
|
color: #92969b;
|
||||||
padding: 8px;
|
padding: 9px 16px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.m-chat-empty {
|
.m-chat-empty {
|
||||||
min-height: 530px;
|
min-height: 530px;
|
||||||
@@ -1337,43 +1478,404 @@ body {
|
|||||||
width: min(280px, 100%);
|
width: min(280px, 100%);
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.m-chat-input {
|
.m-chat-composer {
|
||||||
position: fixed;
|
flex: 0 0 auto;
|
||||||
left: 50%;
|
width: 100%;
|
||||||
bottom: 0;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: min(100%, 750px);
|
|
||||||
height: 72px;
|
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
padding: 10px 15px calc(10px + env(safe-area-inset-bottom));
|
border-top: 1px solid #e8e8e8;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.m-chat-input {
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 10px 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 44px 1fr 44px;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.m-chat-input button {
|
.m-chat-input .m-chat-send {
|
||||||
|
min-width: 66px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
border-radius: 7px;
|
||||||
font-size: 27px;
|
background: linear-gradient(135deg, #e2a25f, #d18437);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 3px 9px rgb(177 105 37 / 20%);
|
||||||
}
|
}
|
||||||
.m-chat-input button img {
|
.m-chat-input .m-chat-send:disabled {
|
||||||
width: 24px;
|
opacity: 0.55;
|
||||||
height: 24px;
|
}
|
||||||
|
.m-chat-tool {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.m-chat-tool img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
.m-chat-input input {
|
.m-chat-input input {
|
||||||
|
min-width: 0;
|
||||||
|
height: 46px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 0 12px;
|
padding: 0 14px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--m-ink);
|
||||||
|
box-shadow: inset 0 0 0 1px transparent;
|
||||||
|
}
|
||||||
|
.m-chat-input input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px #d99a55;
|
||||||
|
}
|
||||||
|
.m-chat-tools {
|
||||||
|
min-height: 142px;
|
||||||
|
padding: 10px 30px 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 82px);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.m-chat-tools button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #7a7d80;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.m-chat-tools button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.m-chat-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.m-chat-tools .n-icon {
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #555;
|
||||||
|
font-size: 31px;
|
||||||
|
}
|
||||||
|
.m-chat-emoji-panel {
|
||||||
|
min-height: 248px;
|
||||||
|
padding: 12px 18px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
}
|
||||||
|
.m-chat-emoji-tabs {
|
||||||
|
min-height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.m-chat-emoji-tabs button {
|
||||||
|
min-width: 54px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8b929d;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.m-chat-emoji-tabs button.is-active {
|
||||||
|
background: #e3efff;
|
||||||
|
color: #1683ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.m-chat-emojis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 12px 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.m-chat-emojis button {
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.m-chat-emojis button:active {
|
||||||
|
background: #eef4fb;
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
.m-chat-emoji-empty {
|
||||||
|
min-height: 142px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #a1a7b0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.m-chat-uploading {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 18px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #8a633d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-chat-uploading progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
accent-color: #d58d2b;
|
||||||
|
}
|
||||||
|
.m-chat-uploading b {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.m-chat-messages {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 20px 24px;
|
||||||
|
}
|
||||||
|
.m-chat-welcome {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 90%;
|
||||||
|
margin: 12px auto 20px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #e5e7e9;
|
||||||
|
color: #74797e;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.m-chat-history-divider {
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #9a9ea4;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-chat-history-divider::before,
|
||||||
|
.m-chat-history-divider::after {
|
||||||
|
content: '';
|
||||||
|
height: 1px;
|
||||||
|
flex: 1;
|
||||||
|
background: #e4e6e9;
|
||||||
|
}
|
||||||
|
.m-chat-session-tip {
|
||||||
|
width: max-content;
|
||||||
|
max-width: 90%;
|
||||||
|
margin: 12px auto 20px;
|
||||||
|
padding: 7px 13px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #fff;
|
||||||
|
color: #647188;
|
||||||
|
font-size: 13px;
|
||||||
|
box-shadow: 0 2px 7px rgb(0 0 0 / 4%);
|
||||||
|
}
|
||||||
|
.m-chat-system-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
.m-chat-avatar--agent span,
|
||||||
|
.m-chat-avatar > span {
|
||||||
|
font-size: 23px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.m-chat-system-message {
|
||||||
|
max-width: calc(100% - 52px);
|
||||||
|
}
|
||||||
|
.m-chat-system-message small {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 6px 2px;
|
||||||
|
color: #68768d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.m-chat-system-message p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border-radius: 4px 14px 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: #e75b9f;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.65;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
.m-chat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.m-chat-row.is-mine {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.m-chat-avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f3c883;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.m-chat-row.is-mine .m-chat-avatar {
|
||||||
|
background: #62a7e8;
|
||||||
|
}
|
||||||
|
.m-chat-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.m-chat-avatar .n-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
.m-chat-message {
|
||||||
|
max-width: calc(78% - 52px);
|
||||||
|
}
|
||||||
|
.m-chat-row.is-mine .m-chat-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.m-chat-bubble {
|
||||||
|
position: relative;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 4px 14px 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
.m-chat-row.is-mine .m-chat-bubble {
|
||||||
|
border-radius: 14px 4px 14px 14px;
|
||||||
|
background: #dfa05f;
|
||||||
|
}
|
||||||
|
.m-chat-bubble p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--m-ink);
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.m-chat-row.is-mine .m-chat-bubble p {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.m-chat-bubble.is-media {
|
||||||
|
padding: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.m-chat-media {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(280px, 100%);
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.m-chat-message > small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #92969b;
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-subpage {
|
.m-subpage {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
.m-subpage--profile {
|
.m-subpage--profile {
|
||||||
background-color: #fffaf5;
|
background-color: #f2f6fc;
|
||||||
background-size: 100% auto;
|
}
|
||||||
background-repeat: no-repeat;
|
.m-profile-info {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 20px 12px 32px;
|
||||||
|
}
|
||||||
|
.m-profile-info-card {
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.m-profile-info-row {
|
||||||
|
min-height: 66px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto 20px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #e9eef5;
|
||||||
|
color: #303846;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.m-profile-info-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.m-profile-info-row > .n-icon:first-child {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.m-profile-info-row > .n-icon:last-child,
|
||||||
|
.m-profile-avatar-row > .n-icon:nth-last-child(2) {
|
||||||
|
grid-column: 4;
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
.m-profile-info-row > b {
|
||||||
|
grid-column: 3 / 5;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-profile-avatar-row {
|
||||||
|
min-height: 94px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-profile-avatar-row input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.m-profile-avatar-preview {
|
||||||
|
grid-column: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.m-profile-avatar-preview > img,
|
||||||
|
.m-profile-avatar-preview > .n-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
font-size: 52px;
|
||||||
|
}
|
||||||
|
.m-profile-avatar-preview small {
|
||||||
|
color: var(--m-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-profile-info > .m-form-error {
|
||||||
|
margin: -10px 6px 0;
|
||||||
|
}
|
||||||
|
.m-bound-account-hint {
|
||||||
|
margin: 16px 20px;
|
||||||
|
color: var(--m-muted);
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.m-profile-edit {
|
.m-profile-edit {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1449,9 +1951,70 @@ body {
|
|||||||
color: var(--m-muted);
|
color: var(--m-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
.m-form-hint a {
|
||||||
|
color: var(--m-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.m-plain-form > .m-form-error {
|
.m-plain-form > .m-form-error {
|
||||||
margin: 15px 30px;
|
margin: 15px 30px;
|
||||||
}
|
}
|
||||||
|
.m-bank-form {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-bottom: 38px;
|
||||||
|
}
|
||||||
|
.m-bank-form > label {
|
||||||
|
min-height: 66px;
|
||||||
|
grid-template-columns: 125px 1fr;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.m-bank-form__divider {
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
background: #f6f6f6;
|
||||||
|
}
|
||||||
|
.m-qr-field {
|
||||||
|
padding: 16px 15px 0;
|
||||||
|
}
|
||||||
|
.m-qr-field h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.m-qr-upload {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2.12 / 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px dashed #d1d1d1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
color: #b8b8b8;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-qr-upload input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.m-qr-upload > span {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.m-qr-upload .n-icon {
|
||||||
|
font-size: 50px;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.m-qr-upload b {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.m-qr-upload img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.m-verify label:nth-child(2) {
|
.m-verify label:nth-child(2) {
|
||||||
grid-template-columns: 125px 1fr auto;
|
grid-template-columns: 125px 1fr auto;
|
||||||
}
|
}
|
||||||
@@ -1841,6 +2404,25 @@ body {
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
.m-managed-rich-text {
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1.75;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.m-managed-rich-text :where(h1, h2, h3, h4) {
|
||||||
|
margin: 1em 0 0.55em;
|
||||||
|
color: inherit;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.m-managed-rich-text :where(p, ul, ol) {
|
||||||
|
margin: 0 0 0.75em;
|
||||||
|
}
|
||||||
|
.m-managed-rich-text :where(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.m-agency-page {
|
.m-agency-page {
|
||||||
padding: 0 18px 32px;
|
padding: 0 18px 32px;
|
||||||
@@ -2080,6 +2662,7 @@ body {
|
|||||||
gap: 22px;
|
gap: 22px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.m-rules-banner strong {
|
.m-rules-banner strong {
|
||||||
font-size: 31px;
|
font-size: 31px;
|
||||||
@@ -2091,10 +2674,12 @@ body {
|
|||||||
.m-rules-row > span:first-child,
|
.m-rules-row > span:first-child,
|
||||||
.m-rules-row > span:last-child {
|
.m-rules-row > span:last-child {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
white-space: nowrap;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.m-rule-level {
|
.m-rule-level {
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
flex-direction: column;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.m-rule-level img {
|
.m-rule-level img {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@@ -2253,9 +2838,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.m-withdraw-confirm {
|
.m-withdraw-confirm {
|
||||||
|
position: relative;
|
||||||
width: min(90vw, 420px);
|
width: min(90vw, 420px);
|
||||||
padding: 22px;
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
.m-withdraw-confirm .m-withdraw-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--m-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.m-withdraw-confirm .m-withdraw-close:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
.m-withdraw-confirm h2 {
|
.m-withdraw-confirm h2 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -2282,6 +2887,16 @@ body {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
.m-withdraw-password-notice {
|
||||||
|
margin: 16px 0;
|
||||||
|
color: var(--m-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.m-withdraw-confirm .m-withdraw-setup-btn,
|
||||||
|
.m-withdraw-confirm .m-withdraw-back-btn {
|
||||||
|
margin: 8px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.m-finance-records {
|
.m-finance-records {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
@@ -2917,6 +3532,13 @@ body {
|
|||||||
background: #ffb21f;
|
background: #ffb21f;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.m-match-steps--single {
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.m-match-steps--single li {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Olons 2 manual task mall */
|
/* Olons 2 manual task mall */
|
||||||
.m-task-mall {
|
.m-task-mall {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
|
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
|
||||||
|
|
||||||
<section class="m-agency-block">
|
<section class="m-agency-block">
|
||||||
<h2><i>1</i>什么是平台代理?</h2>
|
<h2><i>1</i>{{ content?.title || '什么是平台代理?' }}</h2>
|
||||||
|
<div v-if="content" class="m-managed-rich-text" v-html="content.body_html"></div>
|
||||||
|
<template v-else>
|
||||||
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
||||||
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="m-agency-block">
|
<section class="m-agency-block">
|
||||||
@@ -48,12 +51,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import { request } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
|
const content = ref<{ title: string; body_html: string }>();
|
||||||
|
|
||||||
const rewards = [
|
const rewards = [
|
||||||
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
|
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
|
||||||
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
|
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
|
||||||
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
|
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
content.value = await request<{ title: string; body_html: string }>({
|
||||||
|
url: '/v1/contents/agent_cooperation',
|
||||||
|
}).catch(() => undefined);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
v-model.trim="form.username"
|
v-model.trim="form.username"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
placeholder="请输入账户名"
|
placeholder="请输入名字"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="m-input-row">
|
<label class="m-input-row">
|
||||||
@@ -47,8 +47,10 @@
|
|||||||
<n-icon :component="MailOpenOutline" />
|
<n-icon :component="MailOpenOutline" />
|
||||||
<input
|
<input
|
||||||
v-model.trim="form.referral_code"
|
v-model.trim="form.referral_code"
|
||||||
maxlength="32"
|
maxlength="6"
|
||||||
placeholder="请输入推荐码(选填)"
|
inputmode="numeric"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="请输入邀请码(必填)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
|
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
|
||||||
@@ -94,27 +96,47 @@
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const mode = ref<'login' | 'register'>('login');
|
const mode = ref<'login' | 'register'>('login');
|
||||||
const accepted = ref(false);
|
const accepted = ref(true);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
|
const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
|
||||||
|
|
||||||
watch(mode, () => {
|
function referralCodeFromLink() {
|
||||||
|
const value =
|
||||||
|
route.query.inviteCode ?? route.query.invite_code ?? route.query.referral_code;
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(mode, (value) => {
|
||||||
form.username = '';
|
form.username = '';
|
||||||
form.phone = '';
|
form.phone = '';
|
||||||
form.password = '';
|
form.password = '';
|
||||||
form.referral_code = '';
|
form.referral_code = value === 'register' ? referralCodeFromLink() : '';
|
||||||
accepted.value = false;
|
accepted.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.inviteCode, route.query.invite_code, route.query.referral_code],
|
||||||
|
() => {
|
||||||
|
const referralCode = referralCodeFromLink();
|
||||||
|
if (!referralCode) return;
|
||||||
|
mode.value = 'register';
|
||||||
|
form.referral_code = referralCode;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
|
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
|
||||||
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
|
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
|
||||||
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
|
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
|
||||||
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) {
|
if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
|
||||||
return void (error.value = '账户名须为 1 至 10 位小写字母或数字');
|
return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
|
||||||
|
}
|
||||||
|
if (mode.value === 'register' && !/^\d{6}$/.test(form.referral_code)) {
|
||||||
|
return void (error.value = '请输入 6 位数字邀请码');
|
||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
|
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="claiming || !halls.length || eligibility?.eligible === false"
|
:disabled="claiming || !eligibleHalls.length"
|
||||||
aria-label="自动领取任务"
|
aria-label="自动领取任务"
|
||||||
@click="claim"
|
@click="claim"
|
||||||
>
|
>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
|
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning">
|
||||||
<n-icon :component="AlertCircleOutline" />
|
<n-icon :component="AlertCircleOutline" />
|
||||||
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
|
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
|
||||||
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
|
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
|
||||||
@@ -45,20 +45,18 @@
|
|||||||
<n-modal v-model:show="showResult" :mask-closable="false">
|
<n-modal v-model:show="showResult" :mask-closable="false">
|
||||||
<section class="m-claim-result" :class="`is-${resultState}`">
|
<section class="m-claim-result" :class="`is-${resultState}`">
|
||||||
<template v-if="resultState === 'matching'">
|
<template v-if="resultState === 'matching'">
|
||||||
<h2>正在匹配任务</h2>
|
<h2>正在匹配订单</h2>
|
||||||
<p>{{ matchingHint }}</p>
|
<p>{{ matchingHint }}</p>
|
||||||
<div class="m-match-visual">
|
<div class="m-match-visual">
|
||||||
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
|
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
|
||||||
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
|
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
|
<div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
|
||||||
<strong class="m-match-percent">{{ matchProgress }}%</strong>
|
<strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
|
||||||
<ol class="m-match-steps">
|
<ol class="m-match-steps m-match-steps--single">
|
||||||
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }">
|
<li class="active"><span>✓</span>正在匹配订单</li>
|
||||||
<span>✓</span>{{ step }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
<button class="m-result-secondary" @click="openOrders">后台匹配,去记录查看</button>
|
<button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -100,7 +98,7 @@
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
|
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
|
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
|
||||||
@@ -112,14 +110,17 @@
|
|||||||
const halls = ref<any[]>([]);
|
const halls = ref<any[]>([]);
|
||||||
const stats = ref<any>();
|
const stats = ref<any>();
|
||||||
const eligibility = ref<any>();
|
const eligibility = ref<any>();
|
||||||
|
const eligibilityByHall = ref<Record<number, any>>({});
|
||||||
const order = ref<any>();
|
const order = ref<any>();
|
||||||
const showResult = ref(false);
|
const showResult = ref(false);
|
||||||
const resultState = ref<ResultState>('matching');
|
const resultState = ref<ResultState>('matching');
|
||||||
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
|
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
|
||||||
const matchProgress = ref(18);
|
const matchProgress = ref(18);
|
||||||
|
const matchRemainingSeconds = ref(0);
|
||||||
|
const matchTotalSeconds = ref(0);
|
||||||
const pollFinished = ref(false);
|
const pollFinished = ref(false);
|
||||||
let pollTimer: number | undefined;
|
let pollTimer: number | undefined;
|
||||||
let progressTimer: number | undefined;
|
let countdownTimer: number | undefined;
|
||||||
let pollCount = 0;
|
let pollCount = 0;
|
||||||
|
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
@@ -128,14 +129,20 @@
|
|||||||
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
|
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
|
||||||
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
|
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
|
||||||
]);
|
]);
|
||||||
|
const eligibleHalls = computed(() =>
|
||||||
|
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
|
||||||
|
);
|
||||||
const businessHours = computed(() => {
|
const businessHours = computed(() => {
|
||||||
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
|
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
|
||||||
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
|
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
|
||||||
return `${start}-${end}`;
|
return `${start}-${end}`;
|
||||||
});
|
});
|
||||||
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
|
const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual');
|
||||||
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
|
const matchingHint = computed(() => {
|
||||||
const matchingHint = computed(() => pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...');
|
if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...';
|
||||||
|
if (matchRemainingSeconds.value > 0) return `系统正在派单... ${formatCountdown(matchRemainingSeconds.value)}`;
|
||||||
|
return pollFinished.value ? '订单仍在匹配,可稍后前往记录查看' : '系统正在为您匹配订单,请稍等...';
|
||||||
|
});
|
||||||
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
|
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
|
||||||
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
|
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
|
||||||
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
|
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
|
||||||
@@ -154,7 +161,10 @@
|
|||||||
]);
|
]);
|
||||||
halls.value = hallData || [];
|
halls.value = hallData || [];
|
||||||
stats.value = statData;
|
stats.value = statData;
|
||||||
if (halls.value.length) await loadEligibility(halls.value[0].id);
|
if (halls.value.length) {
|
||||||
|
await loadEligibilities();
|
||||||
|
await resumeMatchingOrder();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
error.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,8 +172,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEligibility(hallID: number) {
|
async function loadEligibilities() {
|
||||||
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
|
const entries = await Promise.all(
|
||||||
|
halls.value.map(async (hall) => [
|
||||||
|
hall.id,
|
||||||
|
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
|
||||||
|
] as const)
|
||||||
|
);
|
||||||
|
eligibilityByHall.value = Object.fromEntries(entries);
|
||||||
|
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeMatchingOrder() {
|
||||||
|
const pendingOrderID = Object.values(eligibilityByHall.value)
|
||||||
|
.map((item: any) => Number(item?.pending_order_id || 0))
|
||||||
|
.find((id) => id > 0);
|
||||||
|
if (!pendingOrderID) return;
|
||||||
|
try {
|
||||||
|
const pendingOrder = await request<any>({ url: `/v1/task-orders/${pendingOrderID}` });
|
||||||
|
if (pendingOrder?.status !== 'matching') return;
|
||||||
|
openMatching();
|
||||||
|
handleOrder(pendingOrder);
|
||||||
|
} catch {
|
||||||
|
// 保留页面上的“存在未完成订单”入口,避免详情请求失败阻断领取页加载。
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claim() {
|
async function claim() {
|
||||||
@@ -171,18 +203,33 @@
|
|||||||
claiming.value = true;
|
claiming.value = true;
|
||||||
pollFinished.value = false;
|
pollFinished.value = false;
|
||||||
try {
|
try {
|
||||||
await loadEligibility(halls.value[0].id);
|
await loadEligibilities();
|
||||||
if (!eligibility.value?.eligible) {
|
const candidates = eligibleHalls.value;
|
||||||
|
if (!candidates.length) {
|
||||||
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
|
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openMatching();
|
openMatching();
|
||||||
|
let stockError: unknown;
|
||||||
|
for (const hall of candidates) {
|
||||||
|
eligibility.value = eligibilityByHall.value[hall.id];
|
||||||
|
try {
|
||||||
order.value = await request<any>({
|
order.value = await request<any>({
|
||||||
url: '/v1/task-orders/claim',
|
url: '/v1/task-orders/claim',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { hall_id: halls.value[0].id },
|
data: { hall_id: hall.id },
|
||||||
});
|
});
|
||||||
handleOrder(order.value);
|
handleOrder(order.value);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
|
||||||
|
stockError = err;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw stockError || new Error('当前暂无可领取任务');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failureMessage.value = errorText(err);
|
failureMessage.value = errorText(err);
|
||||||
resultState.value = 'failed';
|
resultState.value = 'failed';
|
||||||
@@ -198,15 +245,23 @@
|
|||||||
resultState.value = 'matching';
|
resultState.value = 'matching';
|
||||||
showResult.value = true;
|
showResult.value = true;
|
||||||
matchProgress.value = 18;
|
matchProgress.value = 18;
|
||||||
|
matchRemainingSeconds.value = 0;
|
||||||
|
matchTotalSeconds.value = 0;
|
||||||
pollCount = 0;
|
pollCount = 0;
|
||||||
progressTimer = window.setInterval(() => {
|
|
||||||
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
|
|
||||||
}, 700);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOrder(value: any) {
|
function handleOrder(value: any) {
|
||||||
order.value = value;
|
order.value = value;
|
||||||
if (value?.status === 'matching') {
|
if (value?.status === 'matching') {
|
||||||
|
if (value?.dispatch_mode === 'manual') {
|
||||||
|
if (countdownTimer) window.clearInterval(countdownTimer);
|
||||||
|
countdownTimer = undefined;
|
||||||
|
matchRemainingSeconds.value = 0;
|
||||||
|
matchTotalSeconds.value = 0;
|
||||||
|
matchProgress.value = 18;
|
||||||
|
} else {
|
||||||
|
startCountdown(value);
|
||||||
|
}
|
||||||
schedulePoll();
|
schedulePoll();
|
||||||
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
|
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
|
||||||
matchProgress.value = 100;
|
matchProgress.value = 100;
|
||||||
@@ -224,6 +279,13 @@
|
|||||||
|
|
||||||
function schedulePoll() {
|
function schedulePoll() {
|
||||||
if (!order.value?.id) return;
|
if (!order.value?.id) return;
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer);
|
||||||
|
const scheduledAt = Date.parse(String(order.value?.scheduled_match_at || ''));
|
||||||
|
const wait = order.value?.dispatch_mode === 'manual'
|
||||||
|
? 10000
|
||||||
|
: Number.isFinite(scheduledAt) && scheduledAt > Date.now()
|
||||||
|
? scheduledAt - Date.now() + 300
|
||||||
|
: 1200;
|
||||||
pollTimer = window.setTimeout(async () => {
|
pollTimer = window.setTimeout(async () => {
|
||||||
pollCount += 1;
|
pollCount += 1;
|
||||||
try {
|
try {
|
||||||
@@ -238,14 +300,44 @@
|
|||||||
pollFinished.value = true;
|
pollFinished.value = true;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
}, 1200);
|
}, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown(value: any) {
|
||||||
|
if (countdownTimer) window.clearInterval(countdownTimer);
|
||||||
|
if (value?.dispatch_mode === 'manual') return;
|
||||||
|
const scheduledAt = Date.parse(String(value?.scheduled_match_at || ''));
|
||||||
|
if (!Number.isFinite(scheduledAt)) return;
|
||||||
|
matchTotalSeconds.value = Math.max(1, Number(value?.dispatch_delay_seconds || 0));
|
||||||
|
const update = () => {
|
||||||
|
matchRemainingSeconds.value = Math.max(0, Math.ceil((scheduledAt - Date.now()) / 1000));
|
||||||
|
const elapsed = Math.max(0, matchTotalSeconds.value - matchRemainingSeconds.value);
|
||||||
|
matchProgress.value = Math.min(88, Math.round(18 + elapsed / matchTotalSeconds.value * 70));
|
||||||
|
if (matchRemainingSeconds.value === 0 && countdownTimer) {
|
||||||
|
window.clearInterval(countdownTimer);
|
||||||
|
countdownTimer = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
if (matchRemainingSeconds.value > 0) countdownTimer = window.setInterval(update, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(totalSeconds: number) {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return hours > 0
|
||||||
|
? [hours, minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':')
|
||||||
|
: [minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(clearProgress = true) {
|
function stopPolling(clearProgress = true) {
|
||||||
if (pollTimer) window.clearTimeout(pollTimer);
|
if (pollTimer) window.clearTimeout(pollTimer);
|
||||||
if (clearProgress && progressTimer) window.clearInterval(progressTimer);
|
if (clearProgress && countdownTimer) window.clearInterval(countdownTimer);
|
||||||
pollTimer = undefined;
|
pollTimer = undefined;
|
||||||
if (clearProgress) progressTimer = undefined;
|
if (clearProgress) {
|
||||||
|
countdownTimer = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeResult() {
|
function closeResult() {
|
||||||
|
|||||||
@@ -2,11 +2,6 @@
|
|||||||
<section
|
<section
|
||||||
class="m-page m-page--white m-subpage"
|
class="m-page m-page--white m-subpage"
|
||||||
:class="{ 'm-subpage--profile': formType === 'profile' }"
|
:class="{ 'm-subpage--profile': formType === 'profile' }"
|
||||||
:style="
|
|
||||||
formType === 'profile'
|
|
||||||
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<PageHeader :title="title" />
|
<PageHeader :title="title" />
|
||||||
<AsyncState
|
<AsyncState
|
||||||
@@ -15,71 +10,94 @@
|
|||||||
:error="error"
|
:error="error"
|
||||||
@retry="loadProfile"
|
@retry="loadProfile"
|
||||||
>
|
>
|
||||||
<div class="m-profile-edit">
|
<div class="m-profile-info">
|
||||||
<div class="m-avatar m-avatar--large"
|
<div class="m-profile-info-card m-card">
|
||||||
><img
|
<label class="m-profile-info-row m-profile-avatar-row">
|
||||||
v-if="safeImage(me?.avatar_url)"
|
<n-icon :component="ImageOutline" />
|
||||||
:src="safeImage(me.avatar_url)"
|
<span>头像</span>
|
||||||
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img
|
<span class="m-profile-avatar-preview">
|
||||||
class="m-avatar-edit"
|
<img v-if="safeImage(me?.avatar_url)" :src="safeImage(me.avatar_url)" alt="用户头像" />
|
||||||
:src="mobileAssets.avatarEdit"
|
<n-icon v-else :component="PersonCircleOutline" />
|
||||||
alt=""
|
<small v-if="avatarUploading">上传中...</small>
|
||||||
/></div>
|
</span>
|
||||||
<div class="m-info-card m-card">
|
<n-icon :component="ChevronForwardOutline" />
|
||||||
<div
|
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="avatarUploading" @change="uploadAvatar" />
|
||||||
><span>昵称</span><b>{{ me?.username }}</b></div
|
</label>
|
||||||
><div
|
</div>
|
||||||
><span>手机号码</span><b>{{ me?.phone }}</b></div
|
<p v-if="avatarError" class="m-form-error">{{ avatarError }}</p>
|
||||||
><div
|
<div class="m-profile-info-card m-card">
|
||||||
><span>邀请码</span><b>{{ me?.invite_code }}</b></div
|
<div class="m-profile-info-row">
|
||||||
>
|
<n-icon :component="PhonePortraitOutline" />
|
||||||
<router-link to="/app/profile/password"
|
<span>我的手机号</span>
|
||||||
><span>密码管理</span><n-icon :component="ChevronForwardOutline"
|
<b>{{ me?.phone }}</b>
|
||||||
/></router-link>
|
</div>
|
||||||
<router-link to="/app/profile/trade-password"
|
<router-link class="m-profile-info-row" to="/app/profile/bank-card?from=profile">
|
||||||
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline"
|
<n-icon :component="CardOutline" />
|
||||||
/></router-link>
|
<span>取出信息</span>
|
||||||
|
<n-icon :component="ChevronForwardOutline" />
|
||||||
|
</router-link>
|
||||||
|
<router-link class="m-profile-info-row" to="/app/profile/password">
|
||||||
|
<n-icon :component="LockClosedOutline" />
|
||||||
|
<span>密码管理</span>
|
||||||
|
<n-icon :component="ChevronForwardOutline" />
|
||||||
|
</router-link>
|
||||||
|
<router-link class="m-profile-info-row" to="/app/profile/trade-password">
|
||||||
|
<n-icon :component="LockClosedOutline" />
|
||||||
|
<span>支付密码管理</span>
|
||||||
|
<n-icon :component="ChevronForwardOutline" />
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AsyncState>
|
</AsyncState>
|
||||||
|
|
||||||
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
|
<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>
|
||||||
<label
|
<label
|
||||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||||
/></label>
|
/></label>
|
||||||
|
<label
|
||||||
|
><span>身份证号</span
|
||||||
|
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
|
||||||
|
/></label>
|
||||||
|
<div class="m-bank-form__divider"></div>
|
||||||
<label
|
<label
|
||||||
><span>银行卡号</span
|
><span>银行卡号</span
|
||||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
|
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
<label
|
||||||
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
|
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
<label
|
||||||
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
|
><span>数字人民币</span
|
||||||
/></label>
|
|
||||||
<label
|
|
||||||
><span>预留手机号</span
|
|
||||||
><input
|
><input
|
||||||
v-model.trim="bank.reserved_phone"
|
v-model.trim="bank.digital_rmb_code"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="11"
|
maxlength="32"
|
||||||
placeholder="请输入预留手机号(选填)"
|
placeholder="请输入数字编码(选填)"
|
||||||
/></label>
|
/></label>
|
||||||
<label
|
<section class="m-qr-field">
|
||||||
><span>交易密码</span
|
<h3>微信二维码</h3>
|
||||||
><input
|
<label class="m-qr-upload">
|
||||||
v-model="bank.trade_password"
|
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
|
||||||
type="password"
|
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
|
||||||
inputmode="numeric"
|
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : bank.wechat_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
|
||||||
maxlength="6"
|
</label>
|
||||||
placeholder="请输入 6 位交易密码"
|
</section>
|
||||||
/></label>
|
<section class="m-qr-field">
|
||||||
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
|
<h3>支付宝二维码</h3>
|
||||||
|
<label class="m-qr-upload">
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('alipay', $event)" />
|
||||||
|
<img v-if="qr.alipay.url" :src="safeImage(qr.alipay.url)" alt="支付宝二维码" />
|
||||||
|
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.alipay.uploading ? '上传中...' : bank.alipay_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
<p v-if="error" class="m-form-error">{{ error }}</p
|
<p v-if="error" class="m-form-error">{{ error }}</p
|
||||||
><button class="m-primary-btn" :disabled="submitting">{{
|
><button class="m-primary-btn" :disabled="submitting || qr.wechat.uploading || qr.alipay.uploading">{{
|
||||||
submitting ? '提交中...' : '确认绑定'
|
submitting ? '提交中...' : existingAccount ? '保存修改' : '确认绑定'
|
||||||
}}</button>
|
}}</button>
|
||||||
</form>
|
</form>
|
||||||
|
</AsyncState>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
v-else-if="formType === 'tradePassword'"
|
v-else-if="formType === 'tradePassword'"
|
||||||
@@ -195,7 +213,8 @@
|
|||||||
<article v-if="content" class="m-content"
|
<article v-if="content" class="m-content"
|
||||||
><h1>{{ content.title }}</h1
|
><h1>{{ content.title }}</h1
|
||||||
><p class="m-content-summary">{{ content.summary }}</p
|
><p class="m-content-summary">{{ content.summary }}</p
|
||||||
><div class="m-content-body">{{ content.body_text || '暂无正文' }}</div
|
><div v-if="content.body_html" class="m-content-body m-managed-rich-text" v-html="content.body_html"></div
|
||||||
|
><div v-else class="m-content-body">{{ content.body_text || '暂无正文' }}</div
|
||||||
><time v-if="content.published_at"
|
><time v-if="content.published_at"
|
||||||
>发布时间:{{ dateTime(content.published_at) }}</time
|
>发布时间:{{ dateTime(content.published_at) }}</time
|
||||||
></article
|
></article
|
||||||
@@ -205,18 +224,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
|
CardOutline,
|
||||||
|
CameraOutline,
|
||||||
ChevronForwardOutline,
|
ChevronForwardOutline,
|
||||||
|
ImageOutline,
|
||||||
KeyOutline,
|
KeyOutline,
|
||||||
LockClosedOutline,
|
LockClosedOutline,
|
||||||
PersonCircleOutline,
|
PersonCircleOutline,
|
||||||
|
PhonePortraitOutline,
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
|
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const formType = computed(() => String(route.meta.form || ''));
|
const formType = computed(() => String(route.meta.form || ''));
|
||||||
@@ -233,20 +255,42 @@
|
|||||||
String(route.meta.title || '')
|
String(route.meta.title || '')
|
||||||
);
|
);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const bankLoading = ref(false);
|
||||||
|
const bankLoadError = ref('');
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const avatarError = ref('');
|
||||||
|
const avatarUploading = ref(false);
|
||||||
const me = ref<any>();
|
const me = ref<any>();
|
||||||
|
interface PaymentAccountDetail {
|
||||||
|
id: number;
|
||||||
|
bank_name: string;
|
||||||
|
holder_name: string;
|
||||||
|
identity_no: string;
|
||||||
|
account_no: string;
|
||||||
|
digital_rmb_code: string;
|
||||||
|
wechat_qr_url: string;
|
||||||
|
alipay_qr_url: string;
|
||||||
|
wechat_qr_attachment_id: number;
|
||||||
|
alipay_qr_attachment_id: number;
|
||||||
|
masked: boolean;
|
||||||
|
}
|
||||||
|
const existingAccount = ref<PaymentAccountDetail | null>(null);
|
||||||
|
let paymentAccountsLoadVersion = 0;
|
||||||
const content = ref<any>();
|
const content = ref<any>();
|
||||||
const resetPassword = ref('');
|
const resetPassword = ref('');
|
||||||
const bank = reactive({
|
const bank = reactive({
|
||||||
account_type: 'bank_card',
|
|
||||||
bank_code: '',
|
|
||||||
bank_name: '',
|
bank_name: '',
|
||||||
holder_name: '',
|
holder_name: '',
|
||||||
|
identity_no: '',
|
||||||
account_no: '',
|
account_no: '',
|
||||||
reserved_phone: '',
|
digital_rmb_code: '',
|
||||||
trade_password: '',
|
wechat_qr_attachment_id: 0,
|
||||||
qr_attachment_id: 0,
|
alipay_qr_attachment_id: 0,
|
||||||
|
});
|
||||||
|
const qr = reactive({
|
||||||
|
wechat: { uploading: false, url: '' },
|
||||||
|
alipay: { uploading: false, url: '' },
|
||||||
});
|
});
|
||||||
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
|
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
|
||||||
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
|
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
|
||||||
@@ -262,28 +306,134 @@
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function uploadAvatar(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file || avatarUploading.value) return;
|
||||||
|
avatarError.value = '';
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
|
||||||
|
avatarError.value = '头像仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
avatarUploading.value = true;
|
||||||
|
try {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('file', file);
|
||||||
|
const uploaded = await request<{ id: number; url: string }>({
|
||||||
|
url: '/v1/attachments/images',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const avatar = await request<{ attachment_id: number; avatar_url: string }>({
|
||||||
|
url: '/v1/me/avatar',
|
||||||
|
method: 'PATCH',
|
||||||
|
data: { attachment_id: uploaded.id },
|
||||||
|
});
|
||||||
|
me.value = { ...me.value, avatar_url: avatar.avatar_url || uploaded.url };
|
||||||
|
window.$message?.success('头像更新成功');
|
||||||
|
} catch (err) {
|
||||||
|
avatarError.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
avatarUploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadPaymentAccounts() {
|
||||||
|
const version = ++paymentAccountsLoadVersion;
|
||||||
|
bankLoading.value = true;
|
||||||
|
bankLoadError.value = '';
|
||||||
|
error.value = '';
|
||||||
|
existingAccount.value = null;
|
||||||
|
Object.assign(bank, { bank_name: '', holder_name: '', identity_no: '', account_no: '', digital_rmb_code: '',
|
||||||
|
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
|
||||||
|
qr.wechat.url = '';
|
||||||
|
qr.alipay.url = '';
|
||||||
|
try {
|
||||||
|
const accounts = await request<PaymentAccountDetail[]>({ url: '/v1/payment-accounts/details' });
|
||||||
|
if (version !== paymentAccountsLoadVersion || formType.value !== 'bank') return;
|
||||||
|
const account = accounts[0];
|
||||||
|
if (!account) return;
|
||||||
|
existingAccount.value = account;
|
||||||
|
bank.bank_name = account.bank_name;
|
||||||
|
bank.wechat_qr_attachment_id = account.wechat_qr_attachment_id;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (version === paymentAccountsLoadVersion && formType.value === 'bank') bankLoadError.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
if (version === paymentAccountsLoadVersion) bankLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
async function saveBank() {
|
async function saveBank() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
if (
|
const identityNo = bank.identity_no.replace(/[ -]/g, '');
|
||||||
!bank.holder_name ||
|
const accountNo = bank.account_no.replace(/[ -]/g, '');
|
||||||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
|
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
|
||||||
!bank.bank_name ||
|
const validationError =
|
||||||
!/^[0-9]{6}$/.test(bank.trade_password)
|
bank.holder_name.length < 2
|
||||||
) {
|
? '请输入至少 2 个字符的姓名'
|
||||||
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
|
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
|
||||||
|
? '请输入正确的 15 位或 18 位身份证号'
|
||||||
|
: !/^\d{8,32}$/.test(accountNo)
|
||||||
|
? '请输入 8 至 32 位银行卡号'
|
||||||
|
: bank.bank_name.length < 2
|
||||||
|
? '请输入开户行'
|
||||||
|
: digitalRMBCode && !/^\d{6,32}$/.test(digitalRMBCode)
|
||||||
|
? '数字人民币编码须为 6 至 32 位数字,也可留空'
|
||||||
|
: !bank.wechat_qr_attachment_id || !bank.alipay_qr_attachment_id
|
||||||
|
? '请上传微信和支付宝二维码'
|
||||||
|
: '';
|
||||||
|
if (validationError) {
|
||||||
|
error.value = validationError;
|
||||||
|
window.$message?.error(validationError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
|
await request({ url: '/v1/payment-accounts', method: existingAccount.value ? 'PUT' : 'POST',
|
||||||
window.$message?.success('提交成功,等待后台核验');
|
data: { ...bank, id: existingAccount.value?.id } });
|
||||||
router.back();
|
window.$message?.success(existingAccount.value ? '绑定信息已更新' : '银行卡绑定成功');
|
||||||
|
await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/app/profile/withdrawal');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
error.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function uploadQRCode(kind: 'wechat' | 'alipay', event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
|
||||||
|
error.value = '二维码仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = qr[kind];
|
||||||
|
target.uploading = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const uploaded = await request<{ id: number; url: string }>({
|
||||||
|
url: '/v1/attachments/images',
|
||||||
|
method: 'POST',
|
||||||
|
data: form,
|
||||||
|
});
|
||||||
|
target.url = uploaded.url;
|
||||||
|
if (kind === 'wechat') bank.wechat_qr_attachment_id = uploaded.id;
|
||||||
|
else bank.alipay_qr_attachment_id = uploaded.id;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
target.uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
async function saveTradePassword() {
|
async function saveTradePassword() {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
|
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
|
||||||
@@ -375,8 +525,16 @@
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
watch(
|
||||||
if (formType.value === 'profile') loadProfile();
|
() => [formType.value, String(route.params.key || '')],
|
||||||
else if (formType.value === 'content') loadContent();
|
([type]) => {
|
||||||
});
|
paymentAccountsLoadVersion += 1;
|
||||||
|
error.value = '';
|
||||||
|
if (type !== 'content') content.value = undefined;
|
||||||
|
if (type === 'profile') void loadProfile();
|
||||||
|
else if (type === 'bank') void loadPaymentAccounts();
|
||||||
|
else if (type === 'content') void loadContent();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="m-home">
|
<section class="m-home">
|
||||||
|
<AnnouncementPopup
|
||||||
|
:show="Boolean(popupNotice)"
|
||||||
|
:notice="popupNotice"
|
||||||
|
@read="markPopupRead"
|
||||||
|
/>
|
||||||
<div class="m-home-hero" :style="bannerStyle">
|
<div class="m-home-hero" :style="bannerStyle">
|
||||||
|
<button
|
||||||
|
v-if="currentBanner && currentBanner.target_type !== 'none'"
|
||||||
|
class="m-home-banner-link"
|
||||||
|
type="button"
|
||||||
|
:aria-label="currentBanner.alt_text || '打开 Banner 链接'"
|
||||||
|
@click="openBanner(currentBanner)"
|
||||||
|
></button>
|
||||||
<label class="m-search">
|
<label class="m-search">
|
||||||
<n-icon :component="SearchOutline" />
|
<n-icon :component="SearchOutline" />
|
||||||
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
|
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
|
||||||
@@ -16,6 +28,16 @@
|
|||||||
>
|
>
|
||||||
<div class="m-coin" aria-hidden="true">¥</div>
|
<div class="m-coin" aria-hidden="true">¥</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="(home?.banners?.length || 0) > 1" class="m-home-banner-dots" aria-label="Banner 切换">
|
||||||
|
<button
|
||||||
|
v-for="(_, index) in home?.banners"
|
||||||
|
:key="index"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: index === activeBannerIndex }"
|
||||||
|
:aria-label="`显示第 ${index + 1} 张 Banner`"
|
||||||
|
@click="selectBanner(index)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-home-body">
|
<div class="m-home-body">
|
||||||
@@ -77,18 +99,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { errorText, money, request, safeImage } from '../api';
|
import AnnouncementPopup, {
|
||||||
|
type AnnouncementNotice,
|
||||||
|
} from '../components/AnnouncementPopup.vue';
|
||||||
|
import {
|
||||||
|
completeLoginNotice,
|
||||||
|
errorText,
|
||||||
|
money,
|
||||||
|
request,
|
||||||
|
safeImage,
|
||||||
|
shouldShowLoginNotice,
|
||||||
|
} from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
interface HomeData {
|
interface HomeData {
|
||||||
profile: { username: string; avatar_url: string; level_name: string };
|
profile: { username: string; avatar_url: string; level_name: string };
|
||||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||||
features: Record<string, boolean>;
|
features: Record<string, boolean>;
|
||||||
banners: Array<{ image_url?: string; title?: string }>;
|
banners: BannerData[];
|
||||||
notices: Array<{ title?: string; content?: string }>;
|
notices: Array<{ title?: string; content?: string }>;
|
||||||
task_summary: {
|
task_summary: {
|
||||||
today_count: number;
|
today_count: number;
|
||||||
@@ -96,6 +128,13 @@
|
|||||||
yesterday_commission_amount: number;
|
yesterday_commission_amount: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
interface BannerData {
|
||||||
|
id: number;
|
||||||
|
image_url?: string;
|
||||||
|
alt_text?: string;
|
||||||
|
target_type: 'none' | 'internal_route' | 'external_url';
|
||||||
|
target_value?: string;
|
||||||
|
}
|
||||||
interface LeaderboardEntry {
|
interface LeaderboardEntry {
|
||||||
rank: number;
|
rank: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -108,9 +147,13 @@
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||||
|
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||||
|
const activeBannerIndex = ref(0);
|
||||||
|
let bannerTimer: number | undefined;
|
||||||
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||||
|
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||||||
const bannerStyle = computed(() => {
|
const bannerStyle = computed(() => {
|
||||||
const url = safeImage(home.value?.banners?.[0]?.image_url);
|
const url = safeImage(currentBanner.value?.image_url);
|
||||||
return {
|
return {
|
||||||
backgroundImage: url
|
backgroundImage: url
|
||||||
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
|
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
|
||||||
@@ -162,21 +205,69 @@
|
|||||||
if (item.enabled) router.push(item.path);
|
if (item.enabled) router.push(item.path);
|
||||||
else window.$message?.warning('该功能暂未开放');
|
else window.$message?.warning('该功能暂未开放');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectBanner(index: number) {
|
||||||
|
activeBannerIndex.value = index;
|
||||||
|
restartBannerTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBanner(banner: BannerData) {
|
||||||
|
const target = String(banner.target_value || '').trim();
|
||||||
|
if (!target) return;
|
||||||
|
if (banner.target_type === 'internal_route' && target.startsWith('/')) {
|
||||||
|
void router.push(target);
|
||||||
|
} else if (banner.target_type === 'external_url' && /^https:\/\//i.test(target)) {
|
||||||
|
window.open(target, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartBannerTimer() {
|
||||||
|
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||||
|
bannerTimer = undefined;
|
||||||
|
const count = home.value?.banners?.length || 0;
|
||||||
|
if (count > 1) {
|
||||||
|
bannerTimer = window.setInterval(() => {
|
||||||
|
activeBannerIndex.value = (activeBannerIndex.value + 1) % count;
|
||||||
|
}, 4500);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
const [homeData, rankData] = await Promise.all([
|
const noticeRequest = shouldShowLoginNotice()
|
||||||
|
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
|
||||||
|
: Promise.resolve(null);
|
||||||
|
const [homeData, rankData, noticeData] = await Promise.all([
|
||||||
request<HomeData>({ url: '/v1/home' }),
|
request<HomeData>({ url: '/v1/home' }),
|
||||||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||||||
|
noticeRequest,
|
||||||
]);
|
]);
|
||||||
home.value = homeData;
|
home.value = homeData;
|
||||||
leaderboard.value = rankData || [];
|
leaderboard.value = rankData || [];
|
||||||
|
popupNotice.value = noticeData;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
error.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(() => home.value?.banners?.length, () => {
|
||||||
|
activeBannerIndex.value = 0;
|
||||||
|
restartBannerTimer();
|
||||||
|
});
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -103,20 +103,56 @@
|
|||||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||||
>
|
>
|
||||||
</section>
|
</section>
|
||||||
|
<section v-if="canSubmit" class="m-order-submit-panel">
|
||||||
|
<div>
|
||||||
|
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
||||||
|
<p v-if="balanceInsufficient">
|
||||||
|
当前可用余额 {{ money(availableAmount, detail.currency) }},订单需支付
|
||||||
|
{{ money(requiredAmount, detail.currency) }},还差
|
||||||
|
{{ money(balanceShortfall, detail.currency) }}。请先储入后再提交。
|
||||||
|
</p>
|
||||||
|
<p v-else>当前余额充足,请确认订单信息后提交。</p>
|
||||||
|
</div>
|
||||||
|
<router-link v-if="balanceInsufficient" class="m-order-recharge-link" to="/app/recharge">
|
||||||
|
去储入
|
||||||
|
</router-link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="submitting || balanceInsufficient"
|
||||||
|
@click="showSubmitConfirm = true"
|
||||||
|
>{{ balanceInsufficient ? '余额不足,无法提交' : '提交确认' }}</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AsyncState>
|
</AsyncState>
|
||||||
|
|
||||||
|
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
|
||||||
|
<section class="m-order-submit-confirm">
|
||||||
|
<h2>确认提交订单</h2>
|
||||||
|
<p>系统将校验账户余额并完成订单;余额不足时需要先前往储入。</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
|
||||||
|
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
|
||||||
|
<div><dt>预计佣金</dt><dd>{{ money(detail?.commission_amount, detail?.currency) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="m-order-submit-actions">
|
||||||
|
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
|
||||||
|
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '确认提交' }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</n-modal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ImageOutline } from '@vicons/ionicons5';
|
import { ImageOutline } from '@vicons/ionicons5';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const isDetail = computed(() => Boolean(route.params.id));
|
const isDetail = computed(() => Boolean(route.params.id));
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -124,6 +160,8 @@
|
|||||||
const orders = ref<any[]>([]);
|
const orders = ref<any[]>([]);
|
||||||
const detail = ref<any>();
|
const detail = ref<any>();
|
||||||
const stats = ref<any>();
|
const stats = ref<any>();
|
||||||
|
const showSubmitConfirm = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
let listController: AbortController | undefined;
|
let listController: AbortController | undefined;
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
@@ -149,6 +187,17 @@
|
|||||||
failed: '失败',
|
failed: '失败',
|
||||||
};
|
};
|
||||||
const statusText = (value: string) => labels[value] || value;
|
const statusText = (value: string) => labels[value] || value;
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => detail.value?.status === 'pending' && detail.value?.allowed_actions?.includes('submit')
|
||||||
|
);
|
||||||
|
const availableAmount = computed(() => Number(stats.value?.available_amount || 0));
|
||||||
|
const requiredAmount = computed(() => Number(detail.value?.total_amount || 0));
|
||||||
|
const balanceShortfall = computed(() =>
|
||||||
|
Math.max(0, requiredAmount.value - availableAmount.value)
|
||||||
|
);
|
||||||
|
const balanceInsufficient = computed(
|
||||||
|
() => canSubmit.value && availableAmount.value < requiredAmount.value
|
||||||
|
);
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
listController?.abort();
|
listController?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -178,18 +227,57 @@
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
detail.value = await request({ url: `/v1/task-orders/${route.params.id}` });
|
const [orderDetail, stat] = await Promise.all([
|
||||||
|
request<any>({ url: `/v1/task-orders/${route.params.id}` }),
|
||||||
|
request<any>({ url: '/v1/tasks/stats' }),
|
||||||
|
]);
|
||||||
|
detail.value = orderDetail;
|
||||||
|
stats.value = stat;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
error.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function submitOrder() {
|
||||||
|
if (!detail.value?.id || submitting.value) return;
|
||||||
|
if (balanceInsufficient.value) {
|
||||||
|
showSubmitConfirm.value = false;
|
||||||
|
window.$message.warning('账户余额不足,请先完成储入');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: `/v1/task-orders/${detail.value.id}/submissions`,
|
||||||
|
method: 'POST',
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
showSubmitConfirm.value = false;
|
||||||
|
window.$message.success('提交成功,佣金已自动入账');
|
||||||
|
await loadDetail();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MobileApiError && err.reason === 'INSUFFICIENT_BALANCE') {
|
||||||
|
showSubmitConfirm.value = false;
|
||||||
|
window.$message.warning('账户余额不足,请先完成储入');
|
||||||
|
await router.push('/app/recharge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.$message.error(errorText(err));
|
||||||
|
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
|
||||||
|
showSubmitConfirm.value = false;
|
||||||
|
await loadDetail();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
function changeTab(value: string) {
|
function changeTab(value: string) {
|
||||||
status.value = value;
|
status.value = value;
|
||||||
loadList();
|
loadList();
|
||||||
}
|
}
|
||||||
function load() {
|
function load() {
|
||||||
|
showSubmitConfirm.value = false;
|
||||||
if (isDetail.value) loadDetail();
|
if (isDetail.value) loadDetail();
|
||||||
else loadList();
|
else loadList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
<section class="m-platform-intro">
|
<section class="m-platform-intro">
|
||||||
<PageHeader title="平台介绍" />
|
<PageHeader title="平台介绍" />
|
||||||
|
|
||||||
|
<AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load">
|
||||||
|
<template v-if="intro">
|
||||||
<header class="m-platform-intro-head">
|
<header class="m-platform-intro-head">
|
||||||
<span>OLONS PLATFORM</span>
|
<span>YUEXUANLI</span>
|
||||||
<h2>平台简介</h2>
|
<h2>{{ intro.title }}</h2>
|
||||||
<p>{{ platformIntroLead }}</p>
|
<p>{{ intro.summary }}</p>
|
||||||
<div><b>{{ platformIntroSections.length }}</b><small>服务条款</small><i></i><b>{{ platformFAQs.length }}</b><small>常见问题</small></div>
|
<div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
||||||
@@ -16,33 +18,60 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="service-terms" class="m-platform-document">
|
<section id="service-terms" class="m-platform-document">
|
||||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>服务条款</h2></div></div>
|
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>{{ intro.title }}</h2></div></div>
|
||||||
<article v-for="section in platformIntroSections" :key="section.title">
|
<article class="m-managed-rich-text" v-html="intro.body_html"></article>
|
||||||
<h3>{{ section.title }}</h3>
|
|
||||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="common-questions" class="m-platform-document m-platform-faq">
|
<section id="common-questions" class="m-platform-document m-platform-faq">
|
||||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>常见问题</h2></div></div>
|
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>{{ faq?.title || '常见问题' }}</h2></div></div>
|
||||||
<article v-for="item in platformFAQs" :key="item.title">
|
<article v-if="faq" class="m-managed-rich-text" v-html="faq.body_html"></article>
|
||||||
<h3>{{ item.title }}</h3>
|
<article v-else><p>暂无常见问题。</p></article>
|
||||||
<p v-for="paragraph in item.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</template>
|
||||||
|
</AsyncState>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import { platformFAQs, platformIntroLead, platformIntroSections } from './platformIntroContent';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
|
import { errorText, request } from '../api';
|
||||||
|
|
||||||
|
interface ManagedContent {
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body_html: string;
|
||||||
|
}
|
||||||
|
|
||||||
const activeSection = ref('service-terms');
|
const activeSection = ref('service-terms');
|
||||||
|
const intro = ref<ManagedContent>();
|
||||||
|
const faq = ref<ManagedContent>();
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
function scrollToSection(id: string) {
|
function scrollToSection(id: string) {
|
||||||
activeSection.value = id;
|
activeSection.value = id;
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [introData, faqData] = await Promise.all([
|
||||||
|
request<ManagedContent>({ url: '/v1/contents/platform_introduction' }),
|
||||||
|
request<ManagedContent>({ url: '/v1/contents/frequently_asked_questions' }).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
intro.value = introData;
|
||||||
|
faq.value = faqData;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -45,11 +45,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { errorText, request } from '../api';
|
import { errorText, request } from '../api';
|
||||||
|
|
||||||
const levels = ref<any[]>([]);
|
const levels = ref<any[]>([]);
|
||||||
|
const router = useRouter();
|
||||||
const channels = ref<any[]>([]);
|
const channels = ref<any[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -104,9 +106,10 @@
|
|||||||
remark: `姓名:${form.name}`,
|
remark: `姓名:${form.name}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
|
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
|
||||||
form.amount = '';
|
form.amount = '';
|
||||||
form.name = '';
|
form.name = '';
|
||||||
|
await router.push({ name: 'MobileSupport' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError.value = errorText(err);
|
formError.value = errorText(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,47 +5,93 @@
|
|||||||
>
|
>
|
||||||
<PageHeader title="规则说明" />
|
<PageHeader title="规则说明" />
|
||||||
|
|
||||||
<div class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
<AsyncState :loading="loading" :error="error" :empty="!levels.length" empty-text="暂无生效的会员等级" @retry="load">
|
||||||
<span>充值赠送金</span>
|
<div v-if="entryLevel" class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||||
<p><b>充值<strong>500元</strong></b><b>赠送<strong>20元</strong></b></p>
|
<span>{{ entryLevel.name }}等级配置</span>
|
||||||
|
<p><b>升级价格<strong>{{ yuan(entryLevel.upgrade_amount) }}</strong></b><b>赠送金额<strong>{{ yuan(entryLevel.gift_amount) }}</strong></b></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-rules-table" role="table" aria-label="会员规则">
|
<div class="m-rules-table" role="table" aria-label="会员规则">
|
||||||
<div class="m-rules-row m-rules-head" role="row">
|
<div class="m-rules-row m-rules-head" role="row">
|
||||||
<b>充值金额</b><b>会员等级</b><b>佣金比例</b><b>每日代购</b><b>额外奖励</b><b>单次提现</b>
|
<b>升级价格</b><b>会员等级</b><b>订单佣金</b><b>每日接单</b><b>赠送金额</b><b>每日提现次数</b>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="level in levels" :key="level.name" class="m-rules-row" role="row">
|
<div v-for="level in levels" :key="level.id" class="m-rules-row" role="row">
|
||||||
<span>{{ level.amount }}</span>
|
<span>≥ {{ yuan(level.upgrade_amount) }}</span>
|
||||||
<span class="m-rule-level"><img :src="level.icon" alt="" />{{ level.name }}</span>
|
<span class="m-rule-level"><img :src="level.icon_url || fallbackIcon(level.code)" alt="" />{{ level.name }}</span>
|
||||||
<span>{{ level.rate }}</span>
|
<span>{{ percent(level.commission_rate) }}</span>
|
||||||
<span>{{ level.orders }}</span>
|
<span>{{ level.daily_order_limit }}单</span>
|
||||||
<span>{{ level.bonus }}</span>
|
<span>{{ yuan(level.gift_amount) }}</span>
|
||||||
<span>{{ level.withdrawal }}</span>
|
<span>{{ level.daily_withdraw_limit }}次</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="m-rules-notes">
|
<section class="m-rules-notes">
|
||||||
<h2><span>03</span> 规则补充说明</h2>
|
<h2><span>03</span> {{ ruleContent?.title || '规则补充说明' }}</h2>
|
||||||
<ul>
|
<div v-if="ruleContent" class="m-managed-rich-text" v-html="ruleContent.body_html"></div>
|
||||||
<li>达到对应单次充值门槛,赠送所列永久会员等级。</li>
|
<ul v-else>
|
||||||
<li>每日代购数量、额外奖励及提现额度以对应档位为准。</li>
|
<li>本页展示后台当前生效的会员等级配置,后台调整后重新进入页面即可查看新规则。</li>
|
||||||
<li>星耀、至尊档位原文佣金比例前后不一致,需核实。</li>
|
<li>订单佣金按订单金额 × 匹配时会员等级的佣金比例计算,实际金额以订单详情为准。</li>
|
||||||
<li>奖励领取条件、实际结算及提现限制请以正式规则为准。</li>
|
<li>每日接单、每日提现均为次数限制;赠送金额为等级配置,实际入账以账户明细为准。</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
</AsyncState>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
|
import { errorText, request } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
const levels = [
|
interface MemberLevel {
|
||||||
{ amount: '≥ 1888元', name: '白银', rate: '0.02', orders: '20单', bonus: '88元', withdrawal: '5000以下', icon: mobileAssets.levelSilver },
|
id: number;
|
||||||
{ amount: '≥ 10000元', name: '黄金', rate: '0.022', orders: '30单', bonus: '188元', withdrawal: '20000以下', icon: mobileAssets.levelGold },
|
code: string;
|
||||||
{ amount: '≥ 30000元', name: '铂金', rate: '0.03', orders: '40单', bonus: '388元', withdrawal: '30000以下', icon: mobileAssets.levelPlatinum },
|
name: string;
|
||||||
{ amount: '≥ 50000元', name: '钻石', rate: '0.035', orders: '50单', bonus: '588元', withdrawal: '50000以下', icon: mobileAssets.levelDiamond },
|
icon_url: string;
|
||||||
{ amount: '≥ 80000元', name: '星耀', rate: '0.04', orders: '80单', bonus: '1888元', withdrawal: '100000以下', icon: mobileAssets.levelStar },
|
upgrade_amount: number;
|
||||||
{ amount: '≥ 180000元', name: '至尊', rate: '0.05', orders: '100单', bonus: '2888元', withdrawal: '无限制', icon: mobileAssets.levelCrown },
|
gift_amount: number;
|
||||||
];
|
daily_order_limit: number;
|
||||||
|
commission_rate: number;
|
||||||
|
daily_withdraw_limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels = ref<MemberLevel[]>([]);
|
||||||
|
const ruleContent = ref<{ title: string; body_html: string }>();
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const entryLevel = computed(() => levels.value[0]);
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
VIP1: mobileAssets.levelSilver,
|
||||||
|
VIP2: mobileAssets.levelSilver,
|
||||||
|
VIP3: mobileAssets.levelGold,
|
||||||
|
VIP4: mobileAssets.levelPlatinum,
|
||||||
|
VIP5: mobileAssets.levelDiamond,
|
||||||
|
VIP6: mobileAssets.levelStar,
|
||||||
|
VIP7: mobileAssets.levelCrown,
|
||||||
|
};
|
||||||
|
|
||||||
|
const yuan = (cents: number) => `${(Number(cents || 0) / 100).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}元`;
|
||||||
|
const percent = (basisPoints: number) => `${(Number(basisPoints || 0) / 100).toFixed(2)}%`;
|
||||||
|
const fallbackIcon = (code: string) => icons[code] || mobileAssets.levelSilver;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [levelData, contentData] = await Promise.all([
|
||||||
|
request<MemberLevel[]>({ url: '/v1/member-levels' }),
|
||||||
|
request<{ title: string; body_html: string }>({ url: '/v1/contents/task_order_rules' }).catch(() => undefined),
|
||||||
|
]);
|
||||||
|
levels.value = levelData;
|
||||||
|
ruleContent.value = contentData;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,64 +37,198 @@
|
|||||||
|
|
||||||
<section v-else class="m-chat">
|
<section v-else class="m-chat">
|
||||||
<PageHeader :title="current?.name || '在线客服'" />
|
<PageHeader :title="current?.name || '在线客服'" />
|
||||||
<div class="m-chat-time">会话内容由客服系统提供</div>
|
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||||
<div class="m-chat-empty">
|
<div v-if="chatLoading" class="m-chat-empty">
|
||||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||||
<h2>正在连接客服</h2>
|
<h2>正在连接客服</h2>
|
||||||
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
|
<p>首次连接会自动建立好友关系</p>
|
||||||
<a
|
</div>
|
||||||
v-if="safeServiceUrl"
|
<div v-else-if="chatError" class="m-chat-empty">
|
||||||
:href="safeServiceUrl"
|
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||||
target="_blank"
|
<h2>连接失败</h2>
|
||||||
rel="noopener noreferrer"
|
<p class="m-form-error">{{ chatError }}</p>
|
||||||
class="m-primary-btn m-btn-link"
|
<button class="m-primary-btn" @click="connectChat">重新连接</button>
|
||||||
>进入客服会话</a
|
</div>
|
||||||
>
|
<div v-else ref="messageList" class="m-chat-messages">
|
||||||
<p v-else class="m-form-error">客服会话地址尚未配置</p>
|
<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="" />
|
||||||
|
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||||
|
<span v-else-if="message.mine">👨🏻💼</span>
|
||||||
|
<span v-else>👩🏻💼</span>
|
||||||
|
</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>
|
||||||
|
<p v-if="message.text">{{ message.text }}</p>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||||
|
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||||
|
</small>
|
||||||
|
</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-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="" />
|
||||||
|
<span v-else>👩🏻💼</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-chat-system-message">
|
||||||
|
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
|
||||||
|
<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 }]">
|
||||||
|
<div class="m-chat-avatar" aria-hidden="true">
|
||||||
|
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||||
|
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||||
|
<span v-else-if="message.mine">👨🏻💼</span>
|
||||||
|
<span v-else>👩🏻💼</span>
|
||||||
|
</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>
|
||||||
|
<p v-if="message.text">{{ message.text }}</p>
|
||||||
|
</div>
|
||||||
|
<small>
|
||||||
|
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||||
|
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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="请输入消息" />
|
||||||
|
<button
|
||||||
|
v-if="draft.trim()"
|
||||||
|
class="m-chat-send"
|
||||||
|
type="submit"
|
||||||
|
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||||
|
>{{ sending ? '发送中' : '发送' }}</button>
|
||||||
|
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||||
|
<img :src="mobileAssets.chatMore" alt="" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div v-if="toolsOpen && emojiOpen" class="m-chat-emoji-panel">
|
||||||
|
<div class="m-chat-emoji-tabs" role="tablist" aria-label="表情分类">
|
||||||
|
<button
|
||||||
|
v-for="category in emojiCategories"
|
||||||
|
:key="category.key"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeEmojiCategory === category.key"
|
||||||
|
:class="{ 'is-active': activeEmojiCategory === category.key }"
|
||||||
|
@click="activeEmojiCategory = category.key"
|
||||||
|
>{{ category.label }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="m-chat-emojis">
|
||||||
|
<button v-for="emoji in visibleEmojis" :key="emoji" type="button" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!visibleEmojis.length" class="m-chat-emoji-empty">暂无最近使用的表情</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-chat-input"
|
|
||||||
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
|
|
||||||
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
|
|
||||||
><img :src="mobileAssets.chatMore" alt="" /></button
|
|
||||||
></div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
|
import {
|
||||||
|
ChatbubbleEllipsesOutline,
|
||||||
|
HappyOutline,
|
||||||
|
HeadsetOutline,
|
||||||
|
ImageOutline,
|
||||||
|
VideocamOutline,
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { errorText, request, safeImage } from '../api';
|
import { deviceID, errorText, request, safeImage } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isChat = computed(() => Boolean(route.params.id));
|
const isChat = computed(() => Boolean(route.params.id));
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const services = ref<any[]>([]);
|
const services = ref<any[]>([]);
|
||||||
|
const me = ref<any>();
|
||||||
const current = computed(
|
const current = computed(
|
||||||
() =>
|
() =>
|
||||||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||||||
services.value[0]
|
services.value[0]
|
||||||
);
|
);
|
||||||
const safeServiceUrl = computed(() => {
|
const chatLoading = ref(false);
|
||||||
const value = current.value?.service_url;
|
const chatError = ref('');
|
||||||
if (!value) return '';
|
const messages = ref<ChatMessage[]>([]);
|
||||||
try {
|
const historyBoundarySeq = ref(0);
|
||||||
const u = new URL(value);
|
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||||
return u.protocol === 'https:' ? u.href : '';
|
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
|
||||||
} catch {
|
const draft = ref('');
|
||||||
return '';
|
const sending = ref(false);
|
||||||
}
|
const mediaSending = ref(false);
|
||||||
});
|
const uploadProgress = ref(0);
|
||||||
|
const photoInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const videoInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const toolsOpen = ref(false);
|
||||||
|
const emojiOpen = ref(false);
|
||||||
|
type EmojiCategory = 'recent' | 'face' | 'gesture' | 'object';
|
||||||
|
const emojiCategories: Array<{ key: EmojiCategory; label: string }> = [
|
||||||
|
{ key: 'recent', label: '最近' },
|
||||||
|
{ key: 'face', label: '表情' },
|
||||||
|
{ key: 'gesture', label: '手势' },
|
||||||
|
{ key: 'object', label: '物品' },
|
||||||
|
];
|
||||||
|
const emojiGroups: Record<Exclude<EmojiCategory, 'recent'>, string[]> = {
|
||||||
|
face: [
|
||||||
|
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭',
|
||||||
|
'😡', '🥳', '😱', '🤯', '🥰', '😇', '😮', '😉', '😆', '😅', '☺️', '😋',
|
||||||
|
'😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷',
|
||||||
|
'🤧', '😏', '😪', '🙄',
|
||||||
|
],
|
||||||
|
gesture: [
|
||||||
|
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '👏', '🙌', '👐', '🤲', '🙏',
|
||||||
|
'👋', '🤝', '💪', '✍️', '☝️', '👆', '👇', '👉', '👈', '🫶', '💅', '🤙',
|
||||||
|
],
|
||||||
|
object: [
|
||||||
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🌹', '🌸', '🎉', '🎁',
|
||||||
|
'⭐', '✨', '🔥', '💯', '🎂', '🍰', '☕', '🍻', '⚽', '🏆', '🚀', '💡',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const recentEmojis = ref<string[]>([]);
|
||||||
|
const activeEmojiCategory = ref<EmojiCategory>('face');
|
||||||
|
const visibleEmojis = computed(() =>
|
||||||
|
activeEmojiCategory.value === 'recent' ? recentEmojis.value : emojiGroups[activeEmojiCategory.value]
|
||||||
|
);
|
||||||
|
const sessionStartedAt = ref(new Date().toISOString());
|
||||||
|
const messageList = ref<HTMLElement | null>(null);
|
||||||
|
let chat: CustomerServiceChat | null = null;
|
||||||
|
let connectionVersion = 0;
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
services.value = await request<any[]>({ url: '/v1/customer-services' });
|
const [serviceRows, profile] = await Promise.all([
|
||||||
|
request<any[]>({ url: '/v1/customer-services' }),
|
||||||
|
request<any>({ url: '/v1/me' }).catch(() => null),
|
||||||
|
]);
|
||||||
|
services.value = serviceRows;
|
||||||
|
me.value = profile;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = errorText(err);
|
const message = errorText(err);
|
||||||
|
error.value = message;
|
||||||
|
if (isChat.value) chatError.value = message;
|
||||||
services.value = [];
|
services.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -105,5 +239,149 @@
|
|||||||
if (status === 'busy') return '忙碌';
|
if (status === 'busy') return '忙碌';
|
||||||
return '离线留言';
|
return '离线留言';
|
||||||
}
|
}
|
||||||
|
function disconnectChat() {
|
||||||
|
connectionVersion += 1;
|
||||||
|
chat?.disconnect();
|
||||||
|
chat = null;
|
||||||
|
messages.value = [];
|
||||||
|
historyBoundarySeq.value = 0;
|
||||||
|
chatLoading.value = false;
|
||||||
|
chatError.value = '';
|
||||||
|
draft.value = '';
|
||||||
|
sending.value = false;
|
||||||
|
mediaSending.value = false;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
toolsOpen.value = false;
|
||||||
|
emojiOpen.value = false;
|
||||||
|
}
|
||||||
|
async function connectChat() {
|
||||||
|
const serviceID = Number(route.params.id);
|
||||||
|
disconnectChat();
|
||||||
|
if (!serviceID) return;
|
||||||
|
const version = connectionVersion;
|
||||||
|
chatLoading.value = true;
|
||||||
|
try {
|
||||||
|
const session = await request<CustomerServiceSession>({
|
||||||
|
url: `/v1/customer-services/${serviceID}/session`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||||
|
});
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
const nextChat = new CustomerServiceChat(session, (rows) => {
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
messages.value = rows;
|
||||||
|
void scrollToBottom();
|
||||||
|
});
|
||||||
|
chat = nextChat;
|
||||||
|
await nextChat.connect(session.token);
|
||||||
|
if (version !== connectionVersion) {
|
||||||
|
nextChat.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tinode delivers cached topic history just after subscribe resolves.
|
||||||
|
// Give that initial batch a brief window so it can be separated from the new session.
|
||||||
|
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
|
||||||
|
sessionStartedAt.value = new Date().toISOString();
|
||||||
|
} catch (err) {
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
chat?.disconnect();
|
||||||
|
chat = null;
|
||||||
|
chatError.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
if (version === connectionVersion) chatLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!chat || sending.value || !draft.value.trim()) return;
|
||||||
|
const activeChat = chat;
|
||||||
|
const version = connectionVersion;
|
||||||
|
const value = draft.value;
|
||||||
|
draft.value = '';
|
||||||
|
toolsOpen.value = false;
|
||||||
|
emojiOpen.value = false;
|
||||||
|
sending.value = true;
|
||||||
|
try {
|
||||||
|
await activeChat.send(value);
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
await scrollToBottom();
|
||||||
|
} catch (err) {
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
draft.value = value;
|
||||||
|
chatError.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
if (version === connectionVersion) sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function sendMedia(type: 'image' | 'video', event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file || !chat || mediaSending.value) return;
|
||||||
|
const activeChat = chat;
|
||||||
|
const version = connectionVersion;
|
||||||
|
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||||
|
if (!file.type.startsWith(`${type}/`)) {
|
||||||
|
window.$message?.error(type === 'image' ? '请选择照片文件' : '请选择视频文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
window.$message?.error(type === 'image' ? '照片不能超过 10MB' : '视频不能超过 50MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toolsOpen.value = false;
|
||||||
|
emojiOpen.value = false;
|
||||||
|
mediaSending.value = true;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
try {
|
||||||
|
await activeChat.sendMedia(file, type, (progress) => {
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
uploadProgress.value = Math.min(99, Math.round(progress * 100));
|
||||||
|
});
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
uploadProgress.value = 100;
|
||||||
|
await scrollToBottom();
|
||||||
|
} catch (err) {
|
||||||
|
if (version !== connectionVersion) return;
|
||||||
|
window.$message?.error(errorText(err));
|
||||||
|
} finally {
|
||||||
|
if (version === connectionVersion) {
|
||||||
|
mediaSending.value = false;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleTools() {
|
||||||
|
toolsOpen.value = !toolsOpen.value;
|
||||||
|
if (!toolsOpen.value) emojiOpen.value = false;
|
||||||
|
}
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
draft.value += emoji;
|
||||||
|
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||||||
|
}
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick();
|
||||||
|
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||||
|
}
|
||||||
|
function messageTime(value: string) {
|
||||||
|
if (!value) return '';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||||||
|
}
|
||||||
|
function messageDateTime(value: string) {
|
||||||
|
if (!value) return '';
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
watch(() => route.params.id, (serviceID) => {
|
||||||
|
if (serviceID) void connectChat();
|
||||||
|
else disconnectChat();
|
||||||
|
}, { immediate: true, flush: 'sync' });
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
|
onBeforeUnmount(disconnectChat);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -41,36 +41,79 @@
|
|||||||
<span>加载中</span>
|
<span>加载中</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-modal v-model:show="showConfirm">
|
<n-modal
|
||||||
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
v-model:show="showConfirm"
|
||||||
|
:auto-focus="false"
|
||||||
|
:trap-focus="false"
|
||||||
|
@after-enter="focusTradePassword"
|
||||||
|
>
|
||||||
|
<form v-if="showTradePasswordSetup" class="m-withdraw-confirm m-card" @submit.prevent="setTradePassword">
|
||||||
|
<button class="m-withdraw-close" type="button" aria-label="关闭弹窗" :disabled="submitting" @click="closeConfirm">
|
||||||
|
<n-icon :component="CloseOutline" />
|
||||||
|
</button>
|
||||||
|
<h2>设置支付密码</h2>
|
||||||
|
<p class="m-withdraw-password-notice">设置完成后,请返回确认申请。</p>
|
||||||
|
<label>
|
||||||
|
<span>新支付密码</span>
|
||||||
|
<input
|
||||||
|
v-model="setupForm.newTradePassword"
|
||||||
|
type="password"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="6"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="请输入 6 位数字"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>登录密码确认</span>
|
||||||
|
<input
|
||||||
|
v-model="setupForm.loginPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="请输入登录密码"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="setupError" class="m-form-error">{{ setupError }}</p>
|
||||||
|
<button class="m-primary-btn" :disabled="submitting">{{ submitting ? '设置中...' : '确认设置' }}</button>
|
||||||
|
<button class="m-outline-btn m-withdraw-back-btn" type="button" @click="showTradePasswordSetup = false">返回确认申请</button>
|
||||||
|
</form>
|
||||||
|
<form v-else class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
||||||
|
<button class="m-withdraw-close" type="button" aria-label="关闭弹窗" :disabled="submitting" @click="closeConfirm">
|
||||||
|
<n-icon :component="CloseOutline" />
|
||||||
|
</button>
|
||||||
<h2>确认取出</h2>
|
<h2>确认取出</h2>
|
||||||
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
||||||
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
|
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
|
||||||
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
|
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
|
||||||
<label>
|
<label v-if="hasTradePassword">
|
||||||
<span>支付密码</span>
|
<span>支付密码</span>
|
||||||
<input
|
<input
|
||||||
|
ref="tradePasswordInput"
|
||||||
v-model="form.tradePassword"
|
v-model="form.tradePassword"
|
||||||
type="password"
|
type="password"
|
||||||
|
name="trade-password"
|
||||||
|
autocomplete="off"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
placeholder="请输入 6 位支付密码"
|
placeholder="请输入 6 位支付密码"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<p v-else class="m-withdraw-password-notice">尚未设置支付密码,请先完成设置。</p>
|
||||||
|
<button v-if="!hasTradePassword" class="m-outline-btn m-withdraw-setup-btn" type="button" @click="openTradePasswordSetup">去设置支付密码</button>
|
||||||
<p v-if="actionError" class="m-form-error">{{ actionError }}</p>
|
<p v-if="actionError" class="m-form-error">{{ actionError }}</p>
|
||||||
<button class="m-primary-btn" :disabled="submitting">确认申请</button>
|
<button class="m-primary-btn" :disabled="submitting || !hasTradePassword">确认申请</button>
|
||||||
</form>
|
</form>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ChevronForwardOutline } from '@vicons/ionicons5';
|
import { ChevronForwardOutline, CloseOutline } from '@vicons/ionicons5';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import AsyncState from '../components/AsyncState.vue';
|
import AsyncState from '../components/AsyncState.vue';
|
||||||
import { errorText, idempotencyKey, money, request } from '../api';
|
import { MobileApiError, errorText, idempotencyKey, money, request } from '../api';
|
||||||
import { mobileAssets } from '../assets';
|
import { mobileAssets } from '../assets';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -82,8 +125,13 @@
|
|||||||
const accounts = ref<any[]>([]);
|
const accounts = ref<any[]>([]);
|
||||||
const quote = ref<any>();
|
const quote = ref<any>();
|
||||||
const showConfirm = ref(false);
|
const showConfirm = ref(false);
|
||||||
|
const showTradePasswordSetup = ref(false);
|
||||||
|
const setupError = ref('');
|
||||||
const withdrawalKey = ref('');
|
const withdrawalKey = ref('');
|
||||||
|
const tradePasswordInput = ref<HTMLInputElement>();
|
||||||
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
|
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
|
||||||
|
const setupForm = reactive({ newTradePassword: '', loginPassword: '' });
|
||||||
|
const hasTradePassword = computed(() => me.value?.has_trade_password === true);
|
||||||
const selectedAccount = computed(() =>
|
const selectedAccount = computed(() =>
|
||||||
accounts.value.find((item) => item.id === form.paymentAccountId)
|
accounts.value.find((item) => item.id === form.paymentAccountId)
|
||||||
);
|
);
|
||||||
@@ -101,6 +149,57 @@
|
|||||||
function openAccountPicker() {
|
function openAccountPicker() {
|
||||||
if (!selectedAccount.value) router.push('/app/profile/bank-card');
|
if (!selectedAccount.value) router.push('/app/profile/bank-card');
|
||||||
}
|
}
|
||||||
|
async function focusTradePassword() {
|
||||||
|
await nextTick();
|
||||||
|
if (hasTradePassword.value && !showTradePasswordSetup.value) tradePasswordInput.value?.focus();
|
||||||
|
}
|
||||||
|
function openTradePasswordSetup() {
|
||||||
|
setupError.value = '';
|
||||||
|
actionError.value = '';
|
||||||
|
setupForm.newTradePassword = '';
|
||||||
|
setupForm.loginPassword = '';
|
||||||
|
showTradePasswordSetup.value = true;
|
||||||
|
}
|
||||||
|
function closeConfirm() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
showConfirm.value = false;
|
||||||
|
showTradePasswordSetup.value = false;
|
||||||
|
actionError.value = '';
|
||||||
|
setupError.value = '';
|
||||||
|
form.tradePassword = '';
|
||||||
|
setupForm.newTradePassword = '';
|
||||||
|
setupForm.loginPassword = '';
|
||||||
|
}
|
||||||
|
async function setTradePassword() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
setupError.value = '';
|
||||||
|
if (!/^\d{6}$/.test(setupForm.newTradePassword) || setupForm.loginPassword.length < 6) {
|
||||||
|
setupError.value = '请输入 6 位数字支付密码及登录密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: '/v1/me/trade-password',
|
||||||
|
method: 'PUT',
|
||||||
|
data: {
|
||||||
|
new_trade_password: setupForm.newTradePassword,
|
||||||
|
login_password: setupForm.loginPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
me.value = { ...me.value, has_trade_password: true };
|
||||||
|
setupForm.newTradePassword = '';
|
||||||
|
setupForm.loginPassword = '';
|
||||||
|
form.tradePassword = '';
|
||||||
|
showTradePasswordSetup.value = false;
|
||||||
|
window.$message?.success('支付密码设置成功,请确认申请');
|
||||||
|
await focusTradePassword();
|
||||||
|
} catch (err) {
|
||||||
|
setupError.value = errorText(err);
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
@@ -138,6 +237,8 @@
|
|||||||
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
|
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
|
||||||
});
|
});
|
||||||
withdrawalKey.value = idempotencyKey();
|
withdrawalKey.value = idempotencyKey();
|
||||||
|
form.tradePassword = '';
|
||||||
|
showTradePasswordSetup.value = false;
|
||||||
showConfirm.value = true;
|
showConfirm.value = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = errorText(err);
|
actionError.value = errorText(err);
|
||||||
@@ -147,6 +248,10 @@
|
|||||||
}
|
}
|
||||||
async function submitWithdrawal() {
|
async function submitWithdrawal() {
|
||||||
if (!quote.value || submitting.value) return;
|
if (!quote.value || submitting.value) return;
|
||||||
|
if (!hasTradePassword.value) {
|
||||||
|
actionError.value = '请先设置支付密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!/^\d{6}$/.test(form.tradePassword)) {
|
if (!/^\d{6}$/.test(form.tradePassword)) {
|
||||||
actionError.value = '请输入 6 位支付密码';
|
actionError.value = '请输入 6 位支付密码';
|
||||||
return;
|
return;
|
||||||
@@ -170,6 +275,9 @@
|
|||||||
await router.replace('/app/profile/records?tab=withdrawal');
|
await router.replace('/app/profile/records?tab=withdrawal');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
actionError.value = errorText(err);
|
actionError.value = errorText(err);
|
||||||
|
if (err instanceof MobileApiError && err.reason === 'TRADE_PASSWORD_NOT_SET') {
|
||||||
|
me.value = { ...me.value, has_trade_password: false };
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'profile/bank-card',
|
path: 'profile/bank-card',
|
||||||
name: 'MobileBankCard',
|
name: 'MobileBankCard',
|
||||||
component: () => import('../mobile/views/FormView.vue'),
|
component: () => import('../mobile/views/FormView.vue'),
|
||||||
meta: { title: '添加银行卡', form: 'bank' },
|
meta: { title: '绑定信息', form: 'bank' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'profile/withdrawal',
|
path: 'profile/withdrawal',
|
||||||
@@ -187,6 +187,26 @@ const router = createRouter({
|
|||||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const staleChunkReloadKey = 'olons:stale-chunk-reload-at';
|
||||||
|
const staleChunkErrorPattern =
|
||||||
|
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i;
|
||||||
|
|
||||||
|
function reloadAfterStaleChunk(error?: unknown) {
|
||||||
|
if (error && !staleChunkErrorPattern.test(String(error))) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastReloadAt = Number(window.sessionStorage.getItem(staleChunkReloadKey) || 0);
|
||||||
|
if (now - lastReloadAt < 10_000) return;
|
||||||
|
window.sessionStorage.setItem(staleChunkReloadKey, String(now));
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('vite:preloadError', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
reloadAfterStaleChunk();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.onError((error) => reloadAfterStaleChunk(error));
|
||||||
|
|
||||||
router.afterEach((route) => {
|
router.afterEach((route) => {
|
||||||
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
|
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'tinode-sdk' {
|
||||||
|
export const Drafty: any;
|
||||||
|
export const Tinode: any;
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user