Compare commits

...
27 Commits
Author SHA1 Message Date
richjohnrambo bce5edfaf5 feat: connect portal content and banner carousel 2026-10-16 18:59:16 +08:00
richjohnrambo a86db10342 fix: edit existing payment account in binding form 2026-10-16 14:58:45 +08:00
richjohnrambo 70cd0e592a fix: isolate customer service chats and reload bank details 2026-10-16 14:01:31 +08:00
richjohnrambo 443622cd3c fix: preserve legacy payment account display 2026-10-16 10:14:41 +08:00
richjohnrambo ecf1a89313 feat: show own withdrawal account details and enable avatar upload 2026-10-16 00:19:11 +08:00
richjohnrambo 07c654b6d4 fix: add close button to withdrawal confirmation modal 2026-10-15 23:08:01 +08:00
richjohnrambo 66121f48e9 fix: allow optional digital RMB code and inline payment password setup 2026-10-15 23:03:59 +08:00
richjohnrambo 6e223e537b feat: open support after recharge submission 2026-10-15 20:54:10 +08:00
richjohnrambo eb96b7ac1f feat: use six-digit numeric member invite codes 2026-10-15 18:41:35 +08:00
richjohnrambo b6a0f819b8 feat: require invitation code for registration 2026-10-15 17:31:42 +08:00
richjohnrambo 05d6ec26ad fix: require balance for automatic task claims 2026-10-15 16:40:39 +08:00
richjohnrambo acf50b9b7c feat: improve member payment and order flows 2026-10-15 15:03:39 +08:00
richjohnrambo 06eaf748ed fix: recover stale IM member credentials 2026-10-14 18:18:18 +08:00
richjohnrambo a6d7c8dda8 fix: inject IM credential into deployment 2026-10-14 18:09:55 +08:00
richjohnrambo 37913c37d1 fix: inject IM credential during deployment 2026-10-14 18:08:47 +08:00
richjohnrambo 48b3564ac9 feat: integrate customer service chat 2026-10-14 16:37:12 +08:00
richjohnrambo f3f59fe7c3 feat add customer service IM client 2026-10-14 10:08:57 +08:00
richjohnrambo dedacfcd9a feat: 完善自动与手动派单流程 2026-10-13 22:13:47 +08:00
richjohnrambo 7eedbde4ab fix: parse configured dispatch delay strings 2026-10-13 17:05:53 +08:00
richjohnrambo acbfaf97a6 fix: hide countdown for manual dispatch 2026-10-13 16:55:53 +08:00
richjohnrambo b3bf41af8b fix: preserve order dispatch mode 2026-10-13 15:45:55 +08:00
richjohnrambo 45ef10b2af feat: add scheduled automatic task matching 2026-10-13 15:21:08 +08:00
Your Name 2a3c411c9b fix: serve integrated frontend from root 2026-10-13 15:06:53 +08:00
Your Name 76421fbc2d docs: fix actions plan formatting 2026-10-13 14:36:41 +08:00
Your Name 5e01e4344b docs: plan manual actions entry 2026-10-13 14:34:39 +08:00
Your Name 4a70b85353 docs: record manual actions branch choice 2026-10-13 14:29:40 +08:00
richjohnrambo b2ebea4c80 feat: 完善移动端会员业务流程 2026-10-13 14:19:55 +08:00
85 changed files with 5384 additions and 752 deletions
+3
View File
@@ -33,5 +33,8 @@ jobs:
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.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 status deployment/olons-server -n olons-server-system --timeout=120s
+2
View File
@@ -26,6 +26,8 @@ go.work.sum
# env file
.env
.idea/
.DS_Store
storage/uploads/
# ---> Node
# Logs
+6 -6
View File
@@ -6,7 +6,7 @@ RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
ENV VITE_PUBLIC_PATH=/admin/
ENV VITE_PUBLIC_PATH=/
RUN pnpm run build
# ---- Stage 2: build the Go backend ----
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
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
WORKDIR /app
RUN apk add --no-cache tzdata
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
COPY --from=builder /out/im_workbench /app/im_workbench
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
COPY --from=builder /out/olons_server /app/olons_server
COPY configs /app/configs
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
ENTRYPOINT ["/app/im_workbench"]
ENTRYPOINT ["/app/olons_server"]
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: build test run web-install web-build
build:
go build -o bin/im_workbench ./cmd/workbench
go build -o bin/olons_server ./cmd/olons_server
test:
go test ./...
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:
cd web && pnpm install --frozen-lockfile
+13 -12
View File
@@ -1,8 +1,8 @@
# Workbench
# Olons Server
移动端任务与账户平台后端。项目是独立应用,业务数据以 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/payment-accounts`
- `POST /v1/payment-accounts`
- `PUT /v1/payment-accounts`
- `GET /v1/payment-accounts/details`
- `POST /v1/withdrawal-orders/quote`
- `POST /v1/withdrawal-orders`
- `GET /v1/withdrawal-orders`
@@ -42,17 +44,16 @@
核心环境变量:
```bash
WORKBENCH_CONFIG=configs/workbench.local.json
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
OLONS_SERVER_CONFIG=configs/olons_server.local.json
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
OLONS_SERVER_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
make web-install
@@ -60,7 +61,7 @@ make web-build
make run
```
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
## 请求头
@@ -70,7 +71,7 @@ X-Request-ID: <request 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`。服务不接受客户端传入租户身份。
## 验证
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"regexp"
"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"
)
@@ -59,9 +59,9 @@ func main() {
log.Fatal("mobile_cipher is missing before migration")
}
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
if err != nil {
log.Fatal(err)
}
@@ -10,8 +10,8 @@ import (
"syscall"
"time"
"git.118im.com/im_team/im_workbench/internal/app"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/app"
"git.118im.com/im_team/olons_server/internal/config"
)
func main() {
@@ -34,7 +34,7 @@ func main() {
errCh := make(chan error, 1)
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) {
errCh <- err
}
@@ -10,13 +10,16 @@
"addr": "127.0.0.1:6379",
"db": 0
},
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"finance_master_key": "olons-finance-master-key-v1-20261013",
"allow_dev_header_auth": true,
"required_api_key": false
}
@@ -10,13 +10,16 @@
"addr": "",
"db": 0
},
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"finance_master_key": "olons-finance-master-key-v1-20261013",
"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`。
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 资源
在 `im-deploy-config` 中新增独立应用:
+4 -4
View File
@@ -31,12 +31,12 @@ Content-Type: application/json
"username": "alice01",
"phone": "13800138000",
"password": "Secret123",
"referral_code": "ABCD2345",
"referral_code": "123456",
"privacy_accepted": true
}
```
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
`referral_code` 必填,必须是 6 位数字,且匹配当前租户下正常会员的 `invite_code`。邀请链接可通过 `inviteCode`、`invite_code` 或 `referral_code` 参数预填注册表单。
## 4 登录
@@ -64,7 +64,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01",
"phone": "13800138000",
"invite_code": "EFGH6789",
"invite_code": "654321",
"token": "...",
"token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z"
@@ -92,7 +92,7 @@ Authorization: Bearer <token>
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
| `REFERRAL_CODE_INVALID` | 400 | 邀请码为空、格式错误或不存在 |
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
@@ -2,12 +2,12 @@
> 版本:V1.0
> 更新日期:2026-10-09
> 适用项目:`im_workbench`
> 适用项目:`olons_server`
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
## 1. 文档说明
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
接口分为两类:
@@ -167,7 +167,7 @@ Content-Type: application/json
"username": "alice01",
"phone": "13800138000",
"password": "Secret123",
"referral_code": "ABCD2345",
"referral_code": "123456",
"privacy_accepted": true
}
```
@@ -177,7 +177,7 @@ Content-Type: application/json
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
| `password` | string | 是 | 6–72 字节,不做 MD5 |
| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 |
| `referral_code` | string | 是 | 6 位数字,必须匹配正常会员邀请码 |
| `privacy_accepted` | boolean | 是 | 必须为 `true` |
注册事务同时写入:
@@ -196,7 +196,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01",
"phone": "13800138000",
"invite_code": "EFGH6789",
"invite_code": "654321",
"token": "random-base64url-token",
"token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z"
@@ -241,7 +241,7 @@ Authorization: Bearer <token>
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 |
| 400 | `REFERRAL_CODE_INVALID` | 邀请码为空、格式错误或不存在 |
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
@@ -265,7 +265,7 @@ GET /v1/me
"username": "alice01",
"phone": "13800138000",
"avatar_url": "",
"invite_code": "EFGH6789",
"invite_code": "654321",
"level": {
"id": 1,
"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`:
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
成功返回订单摘要,包括 `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` | 说明 |
| --- | --- | --- |
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
```
```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`:凭证、审核状态与驳回原因;
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
- `ledgers`:以订单号或提交号关联的钱包流水;
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。
- `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
### 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` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
公开状态:`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. 收款账户和取出(已实现,沙箱)
@@ -870,36 +867,38 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
```http
GET /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
{
"account_type": "bank_card",
"bank_code": "ICBC",
"bank_name": "中国工商银行",
"holder_name": "张三",
"identity_no": "11010119900101123X",
"account_no": "6222000000001234",
"reserved_phone": "13800138000",
"qr_attachment_id": 0,
"trade_password": "654321"
"wechat_qr_attachment_id": 101,
"alipay_qr_attachment_id": 102
}
```
支持的账户类型:
在“取出信息”页面,`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 位数字,空格和连字符会被移除 |
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 |
| `unionpay` | `holder_name`、`account_no` | 银联账户 |
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
| `holder_name` | 是 | 2–64 个字符 |
| `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
| `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
| `bank_name` | 是 | 2–100 个字符 |
| `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=pending`,必须由后台核验为 `verified` 后才能用于取出。
姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
列表项示例:
@@ -907,13 +906,14 @@ POST /v1/payment-accounts
{
"id": 1,
"account_type": "bank_card",
"bank_code": "ICBC",
"bank_code": "BANK",
"bank_name": "中国工商银行",
"holder_mask": "张*",
"mobile_mask": "138****8000",
"identity_no_mask": "1101**********123X",
"account_no_mask": "6222********1234",
"qr_attachment_id": 0,
"verify_status": "pending",
"wechat_qr_attachment_id": 101,
"alipay_qr_attachment_id": 102,
"verify_status": "verified",
"is_default": true,
"created_at": "2026-10-09T10:00:00Z",
"updated_at": "2026-10-09T10:00:00Z"
@@ -111,9 +111,9 @@
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
| --- | --- | --- | --- |
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
+2 -1
View File
@@ -1,4 +1,4 @@
module git.118im.com/im_team/im_workbench
module git.118im.com/im_team/olons_server
go 1.26.0
@@ -15,6 +15,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
+2
View File
@@ -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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
"golang.org/x/crypto/bcrypt"
)
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
func TestProfileMapsMemberCategory(t *testing.T) {
repository := &profileRepository{overview: &store.MemberOverview{
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}
profile, err := service.Profile(context.Background(), 0, 7)
if err != nil {
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)
}
}
+67 -35
View File
@@ -8,13 +8,17 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
@@ -36,9 +40,8 @@ var (
)
var (
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
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}$`)
)
@@ -75,17 +78,18 @@ type Result struct {
}
type Profile struct {
MemberID int64 `json:"member_id"`
MemberNo string `json:"member_no"`
Username string `json:"username"`
Phone string `json:"phone"`
AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"`
Level ProfileLevel `json:"level"`
MemberStatus string `json:"member_status"`
MemberCategory string `json:"member_category"`
CreditScore int `json:"credit_score"`
Wallet ProfileWallet `json:"wallet"`
MemberID int64 `json:"member_id"`
MemberNo string `json:"member_no"`
Username string `json:"username"`
Phone string `json:"phone"`
AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"`
HasTradePassword bool `json:"has_trade_password"`
Level ProfileLevel `json:"level"`
MemberStatus string `json:"member_status"`
MemberCategory string `json:"member_category"`
CreditScore int `json:"credit_score"`
Wallet ProfileWallet `json:"wallet"`
}
type ProfileLevel struct {
@@ -142,10 +146,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if !s.ready {
return nil, ErrUnavailable
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
input.Username = strings.TrimSpace(input.Username)
input.Phone = strings.TrimSpace(input.Phone)
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
if !usernamePattern.MatchString(input.Username) {
if !validMemberName(input.Username) {
return nil, ErrUsernameInvalid
}
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 {
return nil, ErrPasswordInvalid
}
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
if !referralPattern.MatchString(input.ReferralCode) {
return nil, ErrReferralInvalid
}
if !input.PrivacyAccepted {
@@ -169,26 +173,53 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if err != nil {
return nil, err
}
inviteCode, err := newPublicID("", 8)
if err != nil {
return nil, err
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
Mobile: input.Phone,
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
})
if err != nil {
for attempt := 0; attempt < 10; attempt++ {
inviteCode, err := newInviteCode()
if err != nil {
return nil, err
}
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
TenantID: s.cfg.DefaultTenantID,
MemberNo: memberNo,
Username: input.Username,
Mobile: input.Phone,
PasswordHash: string(passwordHash),
ReferralCode: input.ReferralCode,
InviteCode: inviteCode,
})
if errors.Is(err, store.ErrInviteCodeExists) {
continue
}
if errors.Is(err, store.ErrReferralInvalid) {
return nil, ErrReferralInvalid
}
return nil, err
if err != nil {
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) {
@@ -238,6 +269,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
HasTradePassword: row.HasTradePassword != 0,
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
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 {
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) {
+27 -3
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct{}
@@ -43,11 +43,13 @@ func TestRegisterValidation(t *testing.T) {
input RegisterInput
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: "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: "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 {
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) {
s := &Service{}
key := s.loginRateKey("13800138000")
+20 -14
View File
@@ -8,15 +8,18 @@ import (
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/httpapi"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/httpapi"
"git.118im.com/im_team/olons_server/internal/imclient"
"git.118im.com/im_team/olons_server/internal/mall"
"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/redis/go-redis/v9"
)
@@ -26,6 +29,7 @@ type App struct {
redis *redis.Client
api *httpapi.API
frontendPath string
upload config.UploadConfig
}
func New(cfg *config.Config) (*App, error) {
@@ -49,14 +53,16 @@ func New(cfg *config.Config) (*App, error) {
taskService := tasks.New(st)
mallService := mall.New(st)
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 {
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() != "" {
mux.Handle("/admin", frontendHandler(a.frontendDir()))
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
mux.Handle("/", frontendHandler(a.frontendDir()))
}
mux.HandleFunc("/healthz", a.api.Healthz)
mux.Handle("/metrics", promhttp.Handler())
@@ -65,7 +71,7 @@ func (a *App) Handler() http.Handler {
}
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 strings.TrimSpace(a.frontendPath)
@@ -75,7 +81,7 @@ func frontendHandler(dir string) http.Handler {
files := http.FileServer(http.Dir(dir))
index := filepath.Join(dir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin")
path := r.URL.Path
if path == "" {
path = "/"
}
+73
View File
@@ -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
}
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"strings"
"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"
)
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
}
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) {
+3 -3
View File
@@ -9,18 +9,18 @@ import (
func TestSessionKeyFromToken(t *testing.T) {
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 {
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)
}
}
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
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)
}
}
+72 -19
View File
@@ -18,10 +18,29 @@ type Config struct {
MySQL MySQLConfig `json:"mysql"`
Redis RedisConfig `json:"redis"`
Auth AuthConfig `json:"auth"`
Upload UploadConfig `json:"upload"`
IM IMConfig `json:"im"`
ReadTimeout 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 {
DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"`
@@ -42,13 +61,14 @@ type AuthConfig struct {
DefaultTenantID int64 `json:"default_tenant_id"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_key"`
FinanceMasterKey string `json:"finance_master_key"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"`
}
func Load() (*Config, error) {
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)
if err != nil {
return nil, err
@@ -75,34 +95,53 @@ func defaultConfig() *Config {
},
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
Auth: AuthConfig{
SessionKeyPattern: "workbench:session:token:%s",
SessionKeyPattern: "olons_server:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
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,
WriteTimeout: 8 * time.Second,
}
}
func applyEnv(cfg *Config) {
setString(&cfg.Listen, "WORKBENCH_LISTEN")
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
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)
}
}
@@ -133,6 +172,12 @@ func (c *Config) validate() error {
if c.Auth.DefaultTenantID < 0 {
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
}
@@ -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 {
parts := strings.Split(value, ",")
items := make([]string, 0, len(parts))
+209 -112
View File
@@ -19,8 +19,8 @@ import (
"unicode"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
@@ -34,17 +34,17 @@ const (
var (
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}$`)
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
)
type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, 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)
UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error)
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
@@ -68,29 +68,47 @@ type Service struct {
}
type PaymentAccountInput struct {
AccountType string `json:"account_type"`
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
AccountNo string `json:"account_no"`
ReservedPhone string `json:"reserved_phone"`
QRAttachmentID int64 `json:"qr_attachment_id"`
TradePassword string `json:"trade_password"`
ID int64 `json:"id"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
}
type PaymentAccount struct {
ID int64 `json:"id"`
AccountType string `json:"account_type"`
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"`
MobileMask string `json:"mobile_mask"`
AccountNoMask string `json:"account_no_mask"`
QRAttachmentID int64 `json:"qr_attachment_id"`
VerifyStatus string `json:"verify_status"`
IsDefault bool `json:"is_default"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ID int64 `json:"id"`
AccountType string `json:"account_type"`
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"`
IdentityNoMask string `json:"identity_no_mask"`
AccountNoMask string `json:"account_no_mask"`
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
VerifyStatus string `json:"verify_status"`
IsDefault bool `json:"is_default"`
CreatedAt string `json:"created_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 {
@@ -235,10 +253,18 @@ type WalletLedgerPage struct {
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, now: time.Now}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
return service
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
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 {
return service
}
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
@@ -254,6 +280,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
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) {
if s.repository == nil {
return nil, ErrUnavailable
@@ -269,41 +301,57 @@ func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64)
return items, nil
}
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
if !s.ready {
func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) {
if s.repository == nil {
return nil, ErrUnavailable
}
accountType, err := normalizePaymentAccount(&input)
rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
if err != nil {
return nil, err
}
holderCipher, err := s.encrypt(input.HolderName)
if err != nil {
return nil, err
}
accountCipher, err := s.encrypt(input.AccountNo)
if err != nil {
return nil, err
}
mobileCipher := ""
if input.ReservedPhone != "" {
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone)
if err != nil {
return nil, err
items := make([]PaymentAccountDetail, 0, len(rows))
for _, row := range rows {
holderName, accountNo := row.HolderMask, row.AccountNoMask
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
masked := true
if s.ready {
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
if holderErr == nil && accountErr == nil {
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
if row.IdentityNoCipher != "" {
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
identityNo = value
} else {
masked = true
}
}
if row.DigitalRMBCodeCipher != "" {
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
digitalRMBCode = value
} else {
masked = true
}
}
}
}
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,
})
}
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher,
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
QRAttachmentID: input.QRAttachmentID,
})
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 {
return nil, err
}
@@ -311,6 +359,63 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
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) {
if !s.ready {
return nil, ErrUnavailable
@@ -349,9 +454,16 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
reservedPhone := ""
if account.MobileCipher != "" {
reservedPhone, err = s.decrypt(account.MobileCipher)
identityNo := ""
if account.IdentityNoCipher != "" {
identityNo, err = s.decrypt(account.IdentityNoCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
}
digitalRMBCode := ""
if account.DigitalRMBCodeCipher != "" {
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
if err != nil {
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{
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
"bank_name": account.BankName, "holder_name": holderName,
"reserved_phone": reservedPhone, "account_no": accountNo,
"qr_attachment_id": account.QRAttachmentID,
"identity_no": identityNo, "account_no": accountNo,
"digital_rmb_code": digitalRMBCode,
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
})
if err != nil {
return nil, err
@@ -375,10 +489,14 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
Channel: *channel,
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
ExpectedDigitalRMBCodeCipher: account.DigitalRMBCodeCipher,
ExpectedDigitalRMBCodeHash: account.DigitalRMBCodeHash,
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
Channel: *channel,
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
BankName: account.BankName, HolderMask: account.HolderMask,
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
@@ -695,7 +813,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
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()
if err != nil && !errors.Is(err, redis.Nil) {
return nil, ErrUnavailable
@@ -725,58 +843,30 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
return security, nil
}
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
func normalizePaymentAccount(input *PaymentAccountInput) error {
input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName)
input.AccountNo = strings.TrimSpace(input.AccountNo)
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
return 0, ErrAccountFieldsInvalid
}
switch input.AccountType {
case "bank_card":
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
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
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
return ErrAccountFieldsInvalid
}
return nil
}
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
WechatQRAttachmentID: row.WechatQRAttachmentID,
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
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:])
}
func maskIdentityNo(value string) string {
if len(value) <= 8 {
return strings.Repeat("*", len(value))
}
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
}
func validHumanName(value string) bool {
count := utf8.RuneCountInString(value)
if count < 2 || count > 64 {
+152 -10
View File
@@ -7,13 +7,15 @@ import (
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
)
type fakeRepository struct {
config json.RawMessage
account *store.PaymentAccount
details []store.PaymentAccount
channel *store.WithdrawalChannel
wallet *store.WithdrawalWallet
orders []store.WithdrawalOrder
@@ -23,6 +25,24 @@ type fakeRepository struct {
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) {
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) {
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) {
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) {
return f.account, nil
}
@@ -60,7 +86,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
f.createdRecharge = input
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
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) {
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) {
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
accountType, err := normalizePaymentAccount(&input)
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
DigitalRMBCode: "1234 5678",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatal(err)
}
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
input.DigitalRMBCode != "12345678" ||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
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 {
t.Fatal(err)
}
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
maskAccount(input.AccountNo) != "6222********1234" {
t.Fatalf("unexpected normalized input: %#v", input)
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
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 {
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" {
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)
}
}
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")
}
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http"
"strings"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
)
type accountRegisterRequest struct {
+24 -15
View File
@@ -3,25 +3,29 @@ package httpapi
import (
"net/http"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/mall"
"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 {
auth *auth.Authenticator
accounts *accounts.Service
portal *portal.Service
tasks *tasks.Service
mall *mall.Service
finance *finance.Service
auth *auth.Authenticator
accounts *accounts.Service
portal *portal.Service
tasks *tasks.Service
mall *mall.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 {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
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, attachments: attachmentService, support: supportService}
}
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/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
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/tasks/eligibility", a.taskEligibility)
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.manualProduct)
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/recharge/channels", a.rechargeChannels)
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) {
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
}
+36
View File
@@ -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)
}
+30 -6
View File
@@ -6,14 +6,14 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/store"
)
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPut {
methodNotAllowed(w, r, http.MethodGet, http.MethodPost, http.MethodPut)
return
}
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) {
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 {
writeFinanceError(w, r, err)
return
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/store"
)
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
+58 -4
View File
@@ -3,15 +3,19 @@ package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/store"
)
var publicContentKeys = map[string]struct{}{
"privacy_policy": {},
"user_agreement": {},
"privacy_policy": {},
"user_agreement": {},
"platform_intro": {},
"platform_introduction": {},
"frequently_asked_questions": {},
}
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)
}
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 {
if a.portal != nil {
return true
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"errors"
"net/http"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
)
type avatarRequest struct {
+76
View File
@@ -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)
}
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/olons_server/internal/tasks"
)
type taskClaimRequest struct {
+391
View File
@@ -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
}
+151
View File
@@ -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[:])
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct {
+43 -3
View File
@@ -7,8 +7,8 @@ import (
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/store"
)
var (
@@ -22,6 +22,8 @@ type Repository interface {
PublishedContent(context.Context, int64, string) (*store.Content, error)
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
@@ -51,6 +53,7 @@ type Notice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
BodyHTML string `json:"body_html,omitempty"`
SourceType string `json:"source_type"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
@@ -124,6 +127,7 @@ type CustomerService struct {
ServiceURL string `json:"service_url"`
BackupContact string `json:"backup_contact"`
PrivacyNotice string `json:"privacy_notice"`
IMEnabled bool `json:"im_enabled"`
}
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
}
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) {
key = strings.ToLower(strings.TrimSpace(key))
if !contentKeyPattern.MatchString(key) {
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)
if err != nil {
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) != "" {
_ = 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,
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),
BackupContact: row.BackupContact,
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "",
})
}
return items, nil
+38 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/store"
)
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) {
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) {
if r.noticePage == 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)
}
}
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)
}
}
+5 -2
View File
@@ -62,11 +62,14 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
(tenant_id,member_no,username,mobile,password_hash,
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
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,
levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil {
if isDuplicate(err) {
if strings.Contains(err.Error(), "uk_app_member_invite_code") {
return nil, ErrInviteCodeExists
}
return nil, ErrAccountExists
}
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) {
code = strings.TrimSpace(code)
if code == "" {
return 0, 1, "", nil
return 0, 0, "", ErrReferralInvalid
}
var parent struct {
ID int64 `db:"id"`
+34
View File
@@ -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
}
+1
View File
@@ -4,6 +4,7 @@ import "errors"
var (
ErrAccountExists = errors.New("account already exists")
ErrInviteCodeExists = errors.New("invite code already exists")
ErrAccountNotFound = errors.New("account not found")
ErrAccountRestricted = errors.New("account restricted")
ErrAttachmentInvalid = errors.New("attachment invalid")
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/jmoiron/sqlx"
)
+48 -1
View File
@@ -83,6 +83,9 @@ type CustomerService struct {
OnlineStatus int `db:"online_status"`
ServiceURL string `db:"service_url"`
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) {
@@ -161,6 +164,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
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) {
if s == nil || s.db == nil {
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)
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)
return rows, err
}
+4 -2
View File
@@ -13,6 +13,7 @@ type MemberOverview struct {
Username string `db:"username"`
Mobile string `db:"mobile"`
InviteCode string `db:"invite_code"`
HasTradePassword int `db:"has_trade_password"`
MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"`
CreditScore int `db:"credit_score"`
@@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
}
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,
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(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,
@@ -92,7 +94,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
var avatar Avatar
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentInvalid
+6 -4
View File
@@ -56,6 +56,8 @@ type RechargePage struct {
Total int64
}
const rechargeStatusReview = 2
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
items := make([]RechargeChannel, 0)
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 {
var count int
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
}
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
(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)
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
if err != nil {
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,
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) {
+88
View File
@@ -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
}
+426 -64
View File
@@ -2,9 +2,14 @@ package store
import (
"context"
cryptorand "crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"time"
"github.com/jmoiron/sqlx"
@@ -35,6 +40,7 @@ type TaskHall struct {
type TaskEligibility struct {
MemberLevelID int64 `db:"member_level_id"`
MemberStatus int `db:"member_status"`
AutoDispatch int `db:"auto_dispatch"`
HallEligible bool `db:"hall_eligible"`
AvailableAmount int64 `db:"available_amount"`
WalletStatus int `db:"wallet_status"`
@@ -48,6 +54,7 @@ type ClaimTaskInput struct {
MemberID int64
HallID int64
MinimumAmount int64
AutoDelay int
DeadlineAt time.Time
OrderNo string
}
@@ -104,12 +111,15 @@ type TaskStats struct {
}
type TaskSubmissionInput struct {
TenantID int64
MemberID int64
OrderID int64
SubmissionNo string
EvidenceText string
AttachmentIDs []int64
TenantID int64
MemberID int64
OrderID int64
SubmissionNo string
EvidenceText string
AttachmentIDs []int64
LedgerNo string
IdempotencyKey string
SettledAt time.Time
}
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) {
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
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,
@@ -159,9 +169,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
defer tx.Rollback()
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
@@ -194,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if wallet.Status != 1 {
return nil, ErrAccountRestricted
}
if wallet.AvailableAmount < input.MinimumAmount {
if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) {
return nil, ErrInsufficientBalance
}
var pending int64
@@ -207,8 +218,12 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if pending > 0 {
return nil, ErrPendingOrderExists
}
var dailyLimit int
err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l
var levelRule struct {
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
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) {
@@ -217,44 +232,53 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if err != nil {
return nil, err
}
if dailyLimit > 0 {
if levelRule.DailyLimit > 0 {
var todayCount int
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 {
return nil, err
}
if todayCount >= dailyLimit {
if todayCount >= levelRule.DailyLimit {
return nil, ErrDailyLimitReached
}
}
var task struct {
ID int64 `db:"id"`
Title string `db:"title"`
CoverURL string `db:"cover_url"`
CommissionAmount int64 `db:"commission_amount"`
CommissionRate int `db:"commission_rate"`
PerMemberLimit int `db:"per_member_limit"`
}
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
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=?
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
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
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)
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoTaskAvailable
}
if err != nil {
return nil, err
dispatchMode, message := "manual", "等待后台匹配订单"
matchAt := time.Now().UTC()
deadlineAt := input.DeadlineAt.UTC()
delaySeconds := 0
if member.AutoDispatch == 1 {
dispatchMode, message = "auto", "正在自动匹配订单"
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
}
if value, ok := jsonInt(levelRules["auto_order_delay_max_seconds"]); ok {
delayMax = value
}
}
if delayMin < 0 {
delayMin = 0
}
if delayMax < delayMin {
delayMax = delayMin
}
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)
}
}
rules, err := json.Marshal(map[string]any{
"commission_amount": task.CommissionAmount,
"commission_rate": task.CommissionRate,
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
"member_level_id": member.LevelID,
"source": "member_auto_claim",
"dispatch_mode": dispatchMode,
"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,
})
if err != nil {
return nil, err
@@ -262,10 +286,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
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,
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
message,order_status,freeze_status,matched_at,created_by,updated_by)
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
message,order_status,freeze_status,created_by,updated_by)
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
if err != nil {
if isDuplicate(err) {
return nil, ErrPendingOrderExists
@@ -279,11 +302,209 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if err := tx.Commit(); err != nil {
return nil, err
}
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: deadlineAt.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) {
@@ -364,13 +585,16 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
}
defer tx.Rollback()
var order struct {
TaskID int64 `db:"task_id"`
MemberLevelID int64 `db:"member_level_id"`
CommissionAmount int64 `db:"commission_amount"`
OrderStatus int `db:"order_status"`
DeadlineAt string `db:"deadline_at"`
OrderNo string `db:"order_no"`
TaskID int64 `db:"task_id"`
MemberLevelID int64 `db:"member_level_id"`
TotalAmount int64 `db:"total_amount"`
CommissionAmount int64 `db:"commission_amount"`
OrderStatus int `db:"order_status"`
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
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)
@@ -383,8 +607,91 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if order.OrderStatus != orderStatusPending {
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 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)
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
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 {
return nil, err
}
if input.SubmissionNo == "" {
return nil, ErrOrderState
}
attachments, err := json.Marshal(input.AttachmentIDs)
if err != nil {
return nil, err
}
now := time.Now().UTC()
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
now := input.SettledAt.UTC()
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,
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
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(?,?,?,?,?,?,?,?,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 isDuplicate(err) {
return nil, ErrOrderState
}
return nil, err
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
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 {
return nil, err
}
@@ -425,7 +772,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if err := tx.Commit(); err != nil {
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 {
@@ -433,7 +795,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
return nil
}
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)
if err != nil {
return err
+37
View File
@@ -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)
}
}
+203 -83
View File
@@ -10,39 +10,53 @@ import (
)
type PaymentAccount struct {
ID int64 `db:"id"`
AccountType int `db:"account_type"`
BankCode string `db:"bank_code"`
BankName string `db:"bank_name"`
HolderCipher string `db:"holder_cipher"`
HolderMask string `db:"holder_mask"`
MobileCipher string `db:"mobile_cipher"`
MobileMask string `db:"mobile_mask"`
AccountNoCipher string `db:"account_no_cipher"`
AccountNoHash string `db:"account_no_hash"`
AccountNoMask string `db:"account_no_mask"`
QRAttachmentID int64 `db:"qr_attachment_id"`
VerifyStatus int `db:"verify_status"`
IsDefault int `db:"is_default"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
ID int64 `db:"id"`
AccountType int `db:"account_type"`
BankCode string `db:"bank_code"`
BankName string `db:"bank_name"`
HolderCipher string `db:"holder_cipher"`
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"`
MobileMask string `db:"mobile_mask"`
AccountNoCipher string `db:"account_no_cipher"`
AccountNoHash string `db:"account_no_hash"`
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"`
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"`
IsDefault int `db:"is_default"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
type PaymentAccountCreation struct {
TenantID int64
MemberID int64
ExpectedTradePasswordHash string
AccountType int
BankCode string
BankName string
HolderCipher string
HolderMask string
MobileCipher string
MobileMask string
AccountNoCipher string
AccountNoHash string
AccountNoMask string
QRAttachmentID int64
TenantID int64
MemberID int64
AccountType int
BankCode string
BankName string
HolderCipher string
HolderMask string
IdentityNoCipher string
IdentityNoHash string
IdentityNoMask string
AccountNoCipher string
AccountNoHash string
AccountNoMask string
DigitalRMBCodeCipher string
DigitalRMBCodeHash string
DigitalRMBCodeMask string
WechatQRAttachmentID int64
AlipayQRAttachmentID int64
}
type WithdrawalChannel struct {
@@ -67,30 +81,34 @@ type WithdrawalWallet struct {
}
type WithdrawalCreation struct {
TenantID int64
MemberID int64
ExpectedTradePasswordHash string
WithdrawalNo string
PaymentAccountID int64
ExpectedAccountType int
ExpectedBankCode string
ExpectedBankName string
ExpectedHolderCipher string
ExpectedMobileCipher string
ExpectedAccountNoCipher string
ExpectedAccountNoHash string
ExpectedQRAttachmentID int64
Channel WithdrawalChannel
RequestAmount int64
FeeAmount int64
ActualAmount int64
Currency string
AccountSnapshotCipher string
AccountMask string
BankName string
HolderMask string
AvailableLedgerNo string
FrozenLedgerNo string
TenantID int64
MemberID int64
ExpectedTradePasswordHash string
WithdrawalNo string
PaymentAccountID int64
ExpectedAccountType int
ExpectedBankCode string
ExpectedBankName string
ExpectedHolderCipher string
ExpectedIdentityNoCipher string
ExpectedIdentityNoHash string
ExpectedAccountNoCipher string
ExpectedAccountNoHash string
ExpectedDigitalRMBCodeCipher string
ExpectedDigitalRMBCodeHash string
ExpectedWechatQRAttachmentID int64
ExpectedAlipayQRAttachmentID int64
Channel WithdrawalChannel
RequestAmount int64
FeeAmount int64
ActualAmount int64
Currency string
AccountSnapshotCipher string
AccountMask string
BankName string
HolderMask string
AvailableLedgerNo string
FrozenLedgerNo string
}
type WithdrawalOrder struct {
@@ -126,8 +144,11 @@ type WithdrawalPage struct {
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
items := make([]PaymentAccount, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
account_no_mask,qr_attachment_id,verify_status,is_default,
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
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(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
@@ -135,6 +156,33 @@ func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) (
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) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
@@ -143,25 +191,22 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
defer tx.Rollback()
var memberID int64
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`,
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConcurrentUpdate
}
if err != nil {
return nil, err
}
if input.QRAttachmentID > 0 {
var attachmentCount int
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/%'`,
input.TenantID, input.QRAttachmentID, input.MemberID)
if err != nil {
return nil, err
}
if attachmentCount != 1 {
return nil, ErrAttachmentInvalid
}
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
}
var accountCount int
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
@@ -173,12 +218,17 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
isDefault = 1
}
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,
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
(tenant_id,member_id,account_type,bank_code,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,
is_default,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
input.BankCode, 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,
isDefault, input.MemberID, input.MemberID)
if err != nil {
if isDuplicate(err) {
@@ -194,9 +244,64 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
return nil, err
}
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
VerifyStatus: 1, IsDefault: isDefault}, nil
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 (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 {
@@ -209,8 +314,13 @@ func nullableString(value string) any {
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
var row PaymentAccount
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,
account_no_mask,qr_attachment_id,verify_status,is_default,
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
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(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`,
@@ -289,8 +399,14 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
var account PaymentAccount
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,
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
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=?
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) {
@@ -301,8 +417,12 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
}
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
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
}
var channel WithdrawalChannel
+11
View File
@@ -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")
)
+309
View File
@@ -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"
}
}
+125
View File
@@ -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)
}
}
}
+72 -7
View File
@@ -5,12 +5,13 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
const (
@@ -26,6 +27,7 @@ type Repository interface {
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, 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)
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
@@ -75,6 +77,9 @@ type Order struct {
Status string `json:"status"`
StatusText string `json:"status_text"`
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"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -150,6 +155,9 @@ type SubmitResult struct {
type taskPolicy struct {
Enabled bool
MinimumAmount int64
AutoDelay int
AutoMinAmount int64
AutoMaxAmount int64
Start string
End string
Deadline time.Duration
@@ -198,7 +206,7 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
case row.WalletStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case row.AvailableAmount < policy.MinimumAmount:
case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount):
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
@@ -230,14 +238,39 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
}
now := s.now().UTC()
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 {
return nil, err
}
result := orderFromStore(*row, false)
s.scheduleAutoMatch(tenantID, memberID, row.ID, row.RuleSnapshot, policy)
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) {
if page == 0 {
page = 1
@@ -274,6 +307,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
if err != nil {
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})
if err != nil {
return nil, err
@@ -300,17 +351,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
}
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
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
}
now := s.now().UTC()
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
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 {
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
}
@@ -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")
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
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
}
@@ -341,6 +396,12 @@ func orderFromStore(row store.TaskOrder, detail bool) Order {
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
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 {
return result
}
@@ -382,7 +443,11 @@ func orderTimeline(row store.TaskOrder) []TimelineEntry {
func allowedActions(row store.TaskOrder) []string {
switch row.OrderStatus {
case 2:
return []string{"submit", "contact_support"}
var snapshot map[string]any
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
return []string{"submit", "contact_support"}
}
return []string{"contact_support"}
case 3, 5, 6, 8:
return []string{"contact_support"}
default:
+59 -12
View File
@@ -4,10 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
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) {
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
}
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) {
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) {
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
}
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) {
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.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) {
repository := &fakeRepository{config: json.RawMessage(`{}`)}
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}`)}
service := New(repository)
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
@@ -85,29 +127,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
if err != nil {
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)) {
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
}
}
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
repository := &fakeRepository{}
service := New(repository)
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) {
t.Fatalf("expected evidence error, got %v", err)
}
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
if err != nil {
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)
}
}
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) {
service := New(&fakeRepository{})
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
+2 -1
View File
@@ -19,7 +19,8 @@ class DeploymentContractTest(unittest.TestCase):
dockerfile = (ROOT / "Dockerfile").read_text()
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 /app/web/dist", dockerfile)
self.assertIn("EXPOSE 6061", dockerfile)
+1
View File
@@ -12,6 +12,7 @@
"@vicons/ionicons5": "^0.12.0",
"axios": "^1.7.7",
"naive-ui": "^2.44.1",
"tinode-sdk": "^0.25.4",
"vue": "^3.4.38",
"vue-router": "^4.4.3"
},
+17
View File
@@ -17,6 +17,9 @@ importers:
naive-ui:
specifier: ^2.44.1
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
tinode-sdk:
specifier: ^0.25.4
version: 0.25.4
vue:
specifier: ^3.4.38
version: 3.4.38(typescript@5.5.4)
@@ -258,46 +261,55 @@ packages:
resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.21.2':
resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.21.2':
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.21.2':
resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.21.2':
resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.21.2':
resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.21.2':
resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.21.2':
resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.21.2':
resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.21.2':
resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==}
@@ -574,6 +586,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
tinode-sdk@0.25.4:
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@@ -1151,6 +1166,8 @@ snapshots:
source-map-support: 0.5.21
optional: true
tinode-sdk@0.25.4: {}
to-fast-properties@2.0.0: {}
treemate@0.3.11: {}
+24
View File
@@ -2,12 +2,14 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
const TOKEN_KEY = 'olons_web_session';
const DEVICE_KEY = 'olons_web_device';
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
const reasonMessages: Record<string, string> = {
AUTH_REQUIRED: '登录已失效,请重新登录',
FORBIDDEN: '当前账户无权执行此操作',
INVALID_CREDENTIALS: '手机号或密码错误',
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
RATE_LIMITED: '操作过于频繁,请稍后再试',
TASK_DISABLED: '领取功能暂未开放',
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
@@ -19,6 +21,9 @@ const reasonMessages: Record<string, string> = {
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
TRADE_PASSWORD_INVALID: '支付密码不正确',
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
IMAGE_INVALID: '图片格式或大小不符合要求',
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
WITHDRAWAL_DISABLED: '取出功能暂未开放',
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
@@ -28,6 +33,11 @@ const reasonMessages: Record<string, string> = {
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
INTERNAL: '服务异常,请稍后重试',
};
@@ -130,18 +140,32 @@ function getDeviceID() {
return value;
}
export function deviceID() {
return getDeviceID();
}
export function saveSession(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
}
export function clearSession() {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
}
export function hasSession() {
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() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
-2
View File
@@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png';
import processMaterial from './assets/process-material.png';
import processTime from './assets/process-time.png';
import processReturn from './assets/process-return.png';
import chatVoice from './assets/chat-voice.png';
import chatMore from './assets/chat-more.png';
import companyPlatform from './assets/company-platform.png';
import companyRules from './assets/company-rules.png';
@@ -108,7 +107,6 @@ export const mobileAssets = {
processMaterial,
processTime,
processReturn,
chatVoice,
chatMore,
companyPlatform,
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>
+254
View File
@@ -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
View File
@@ -375,12 +375,45 @@ body {
background: var(--m-bg);
}
.m-home-hero {
position: relative;
min-height: 305px;
padding: calc(34px + env(safe-area-inset-top)) 30px 26px;
color: #fff;
background-color: #1b1711;
background-size: cover;
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 {
height: 48px;
@@ -1008,6 +1041,108 @@ body {
text-align: right;
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 {
display: grid;
place-items: center;
@@ -1301,18 +1436,24 @@ body {
background: #c86d45;
}
.m-chat {
min-height: 100dvh;
height: 100dvh;
background: #f4f5f6;
padding-bottom: 80px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.m-chat .m-page-header {
flex: 0 0 auto;
padding-inline: 20px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.m-chat-time {
flex: 0 0 auto;
text-align: center;
color: #92969b;
padding: 8px;
padding: 9px 16px;
font-size: 13px;
}
.m-chat-empty {
min-height: 530px;
@@ -1337,43 +1478,404 @@ body {
width: min(280px, 100%);
margin-top: 16px;
}
.m-chat-input {
position: fixed;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: min(100%, 750px);
height: 72px;
.m-chat-composer {
flex: 0 0 auto;
width: 100%;
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;
grid-template-columns: 44px 1fr 44px;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
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;
background: transparent;
font-size: 27px;
border-radius: 7px;
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 {
width: 24px;
height: 24px;
.m-chat-input .m-chat-send:disabled {
opacity: 0.55;
}
.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;
}
.m-chat-input input {
min-width: 0;
height: 46px;
border: 0;
border-radius: 8px;
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 {
background: #fff;
}
.m-subpage--profile {
background-color: #fffaf5;
background-size: 100% auto;
background-repeat: no-repeat;
background-color: #f2f6fc;
}
.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 {
text-align: center;
@@ -1449,9 +1951,70 @@ body {
color: var(--m-muted);
line-height: 1.6;
}
.m-form-hint a {
color: var(--m-primary);
font-weight: 600;
}
.m-plain-form > .m-form-error {
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) {
grid-template-columns: 125px 1fr auto;
}
@@ -1841,6 +2404,25 @@ body {
font-size: 17px;
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 {
padding: 0 18px 32px;
@@ -2080,6 +2662,7 @@ body {
gap: 22px;
margin-top: 20px;
font-size: 18px;
flex-wrap: wrap;
}
.m-rules-banner strong {
font-size: 31px;
@@ -2091,10 +2674,12 @@ body {
.m-rules-row > span:first-child,
.m-rules-row > span:last-child {
font-size: 10px;
white-space: nowrap;
overflow-wrap: anywhere;
}
.m-rule-level {
gap: 1px;
flex-direction: column;
white-space: normal;
}
.m-rule-level img {
width: 22px;
@@ -2253,9 +2838,29 @@ body {
}
}
.m-withdraw-confirm {
position: relative;
width: min(90vw, 420px);
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 {
margin: 0 0 12px;
text-align: center;
@@ -2282,6 +2887,16 @@ body {
padding: 0 12px;
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 {
padding-inline: 0;
@@ -2917,6 +3532,13 @@ body {
background: #ffb21f;
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 */
.m-task-mall {
+16 -3
View File
@@ -7,9 +7,12 @@
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
<section class="m-agency-block">
<h2><i>1</i>什么是平台代理?</h2>
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
<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>
</template>
</section>
<section class="m-agency-block">
@@ -48,12 +51,22 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import { request } from '../api';
import { mobileAssets } from '../assets';
const content = ref<{ title: string; body_html: string }>();
const rewards = [
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
{ 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>
+31 -9
View File
@@ -21,7 +21,7 @@
v-model.trim="form.username"
autocomplete="username"
maxlength="10"
placeholder="请输入账户名"
placeholder="请输入名字"
/>
</label>
<label class="m-input-row">
@@ -47,8 +47,10 @@
<n-icon :component="MailOpenOutline" />
<input
v-model.trim="form.referral_code"
maxlength="32"
placeholder="请输入推荐码(选填)"
maxlength="6"
inputmode="numeric"
autocomplete="off"
placeholder="请输入邀请码(必填)"
/>
</label>
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
@@ -94,27 +96,47 @@
const route = useRoute();
const router = useRouter();
const mode = ref<'login' | 'register'>('login');
const accepted = ref(false);
const accepted = ref(true);
const submitting = ref(false);
const error = ref('');
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.phone = '';
form.password = '';
form.referral_code = '';
accepted.value = false;
form.referral_code = value === 'register' ? referralCodeFromLink() : '';
accepted.value = true;
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() {
error.value = '';
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) {
return void (error.value = '账户名须为 1 至 10 位小写字母或数字');
if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
}
if (mode.value === 'register' && !/^\d{6}$/.test(form.referral_code)) {
return void (error.value = '请输入 6 位数字邀请码');
}
submitting.value = true;
try {
+124 -32
View File
@@ -5,7 +5,7 @@
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button
type="button"
:disabled="claiming || !halls.length || eligibility?.eligible === false"
:disabled="claiming || !eligibleHalls.length"
aria-label="自动领取任务"
@click="claim"
>
@@ -22,7 +22,7 @@
</div>
</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" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
<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">
<section class="m-claim-result" :class="`is-${resultState}`">
<template v-if="resultState === 'matching'">
<h2>正在匹配任务</h2>
<h2>正在匹配订单</h2>
<p>{{ matchingHint }}</p>
<div class="m-match-visual">
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
</div>
<div class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps">
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }">
<span>✓</span>{{ step }}
</li>
<div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps m-match-steps--single">
<li class="active"><span>✓</span>正在匹配订单</li>
</ol>
<button class="m-result-secondary" @click="openOrders">后台匹配,去记录查看</button>
<button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
</template>
<template v-else>
@@ -100,7 +98,7 @@
import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
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';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
@@ -112,14 +110,17 @@
const halls = ref<any[]>([]);
const stats = ref<any>();
const eligibility = ref<any>();
const eligibilityByHall = ref<Record<number, any>>({});
const order = ref<any>();
const showResult = ref(false);
const resultState = ref<ResultState>('matching');
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
const matchProgress = ref(18);
const matchRemainingSeconds = ref(0);
const matchTotalSeconds = ref(0);
const pollFinished = ref(false);
let pollTimer: number | undefined;
let progressTimer: number | undefined;
let countdownTimer: number | undefined;
let pollCount = 0;
const statCards = computed(() => [
@@ -128,14 +129,20 @@
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ 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 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);
return `${start}-${end}`;
});
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
const matchingHint = computed(() => pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...');
const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual');
const matchingHint = computed(() => {
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 resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
@@ -154,7 +161,10 @@
]);
halls.value = hallData || [];
stats.value = statData;
if (halls.value.length) await loadEligibility(halls.value[0].id);
if (halls.value.length) {
await loadEligibilities();
await resumeMatchingOrder();
}
} catch (err) {
error.value = errorText(err);
} finally {
@@ -162,8 +172,30 @@
}
}
async function loadEligibility(hallID: number) {
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
async function loadEligibilities() {
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() {
@@ -171,18 +203,33 @@
claiming.value = true;
pollFinished.value = false;
try {
await loadEligibility(halls.value[0].id);
if (!eligibility.value?.eligible) {
await loadEligibilities();
const candidates = eligibleHalls.value;
if (!candidates.length) {
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
return;
}
openMatching();
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: halls.value[0].id },
});
handleOrder(order.value);
let stockError: unknown;
for (const hall of candidates) {
eligibility.value = eligibilityByHall.value[hall.id];
try {
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: hall.id },
});
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) {
failureMessage.value = errorText(err);
resultState.value = 'failed';
@@ -198,15 +245,23 @@
resultState.value = 'matching';
showResult.value = true;
matchProgress.value = 18;
matchRemainingSeconds.value = 0;
matchTotalSeconds.value = 0;
pollCount = 0;
progressTimer = window.setInterval(() => {
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
}, 700);
}
function handleOrder(value: any) {
order.value = value;
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();
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
matchProgress.value = 100;
@@ -224,6 +279,13 @@
function schedulePoll() {
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 () => {
pollCount += 1;
try {
@@ -238,14 +300,44 @@
pollFinished.value = true;
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) {
if (pollTimer) window.clearTimeout(pollTimer);
if (clearProgress && progressTimer) window.clearInterval(progressTimer);
if (clearProgress && countdownTimer) window.clearInterval(countdownTimer);
pollTimer = undefined;
if (clearProgress) progressTimer = undefined;
if (clearProgress) {
countdownTimer = undefined;
}
}
function closeResult() {
+230 -72
View File
@@ -2,11 +2,6 @@
<section
class="m-page m-page--white m-subpage"
:class="{ 'm-subpage--profile': formType === 'profile' }"
:style="
formType === 'profile'
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
: undefined
"
>
<PageHeader :title="title" />
<AsyncState
@@ -15,71 +10,94 @@
:error="error"
@retry="loadProfile"
>
<div class="m-profile-edit">
<div class="m-avatar m-avatar--large"
><img
v-if="safeImage(me?.avatar_url)"
:src="safeImage(me.avatar_url)"
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img
class="m-avatar-edit"
:src="mobileAssets.avatarEdit"
alt=""
/></div>
<div class="m-info-card m-card">
<div
><span>昵称</span><b>{{ me?.username }}</b></div
><div
><span>手机号码</span><b>{{ me?.phone }}</b></div
><div
><span>邀请码</span><b>{{ me?.invite_code }}</b></div
>
<router-link to="/app/profile/password"
><span>密码管理</span><n-icon :component="ChevronForwardOutline"
/></router-link>
<router-link to="/app/profile/trade-password"
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline"
/></router-link>
<div class="m-profile-info">
<div class="m-profile-info-card m-card">
<label class="m-profile-info-row m-profile-avatar-row">
<n-icon :component="ImageOutline" />
<span>头像</span>
<span class="m-profile-avatar-preview">
<img v-if="safeImage(me?.avatar_url)" :src="safeImage(me.avatar_url)" alt="用户头像" />
<n-icon v-else :component="PersonCircleOutline" />
<small v-if="avatarUploading">上传中...</small>
</span>
<n-icon :component="ChevronForwardOutline" />
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="avatarUploading" @change="uploadAvatar" />
</label>
</div>
<p v-if="avatarError" class="m-form-error">{{ avatarError }}</p>
<div class="m-profile-info-card m-card">
<div class="m-profile-info-row">
<n-icon :component="PhonePortraitOutline" />
<span>我的手机号</span>
<b>{{ me?.phone }}</b>
</div>
<router-link class="m-profile-info-row" to="/app/profile/bank-card?from=profile">
<n-icon :component="CardOutline" />
<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>
</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
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label>
<label
><span>身份证号</span
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
/></label>
<div class="m-bank-form__divider"></div>
<label
><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
/></label>
<label
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
/></label>
<label
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
/></label>
<label
><span>预留手机号</span
><span>数字人民币</span
><input
v-model.trim="bank.reserved_phone"
v-model.trim="bank.digital_rmb_code"
inputmode="numeric"
maxlength="11"
placeholder="请输入预留手机号(选填)"
maxlength="32"
placeholder="请输入数字编码(选填)"
/></label>
<label
><span>交易密码</span
><input
v-model="bank.trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位交易密码"
/></label>
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
<section class="m-qr-field">
<h3>微信二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : bank.wechat_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
</label>
</section>
<section class="m-qr-field">
<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
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确认绑定'
><button class="m-primary-btn" :disabled="submitting || qr.wechat.uploading || qr.alipay.uploading">{{
submitting ? '提交中...' : existingAccount ? '保存修改' : '确认绑定'
}}</button>
</form>
</AsyncState>
<form
v-else-if="formType === 'tradePassword'"
@@ -195,7 +213,8 @@
<article v-if="content" class="m-content"
><h1>{{ content.title }}</h1
><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"
>发布时间:{{ dateTime(content.published_at) }}</time
></article
@@ -205,18 +224,21 @@
</template>
<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 {
CardOutline,
CameraOutline,
ChevronForwardOutline,
ImageOutline,
KeyOutline,
LockClosedOutline,
PersonCircleOutline,
PhonePortraitOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
const route = useRoute();
const router = useRouter();
const formType = computed(() => String(route.meta.form || ''));
@@ -233,20 +255,42 @@
String(route.meta.title || '')
);
const loading = ref(false);
const bankLoading = ref(false);
const bankLoadError = ref('');
const submitting = ref(false);
const error = ref('');
const avatarError = ref('');
const avatarUploading = ref(false);
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 resetPassword = ref('');
const bank = reactive({
account_type: 'bank_card',
bank_code: '',
bank_name: '',
holder_name: '',
identity_no: '',
account_no: '',
reserved_phone: '',
trade_password: '',
qr_attachment_id: 0,
digital_rmb_code: '',
wechat_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 loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
@@ -262,28 +306,134 @@
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() {
error.value = '';
if (
!bank.holder_name ||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
!bank.bank_name ||
!/^[0-9]{6}$/.test(bank.trade_password)
) {
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
const identityNo = bank.identity_no.replace(/[ -]/g, '');
const accountNo = bank.account_no.replace(/[ -]/g, '');
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
const validationError =
bank.holder_name.length < 2
? '请输入至少 2 个字符的姓名'
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
? '请输入正确的 15 位或 18 位身份证号'
: !/^\d{8,32}$/.test(accountNo)
? '请输入 8 至 32 位银行卡号'
: bank.bank_name.length < 2
? '请输入开户行'
: 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;
}
submitting.value = true;
try {
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
window.$message?.success('提交成功,等待后台核验');
router.back();
await request({ url: '/v1/payment-accounts', method: existingAccount.value ? 'PUT' : 'POST',
data: { ...bank, id: existingAccount.value?.id } });
window.$message?.success(existingAccount.value ? '绑定信息已更新' : '银行卡绑定成功');
await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/app/profile/withdrawal');
} catch (err) {
error.value = errorText(err);
} finally {
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() {
error.value = '';
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
@@ -375,8 +525,16 @@
loading.value = false;
}
}
onMounted(() => {
if (formType.value === 'profile') loadProfile();
else if (formType.value === 'content') loadContent();
});
watch(
() => [formType.value, String(route.params.key || '')],
([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>
+96 -5
View File
@@ -1,6 +1,18 @@
<template>
<section class="m-home">
<AnnouncementPopup
:show="Boolean(popupNotice)"
:notice="popupNotice"
@read="markPopupRead"
/>
<div class="m-home-hero" :style="bannerStyle">
<button
v-if="currentBanner && currentBanner.target_type !== 'none'"
class="m-home-banner-link"
type="button"
:aria-label="currentBanner.alt_text || '打开 Banner 链接'"
@click="openBanner(currentBanner)"
></button>
<label class="m-search">
<n-icon :component="SearchOutline" />
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
@@ -16,6 +28,16 @@
>
<div class="m-coin" aria-hidden="true">¥</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 class="m-home-body">
@@ -77,18 +99,28 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
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';
interface HomeData {
profile: { username: string; avatar_url: string; level_name: string };
wallet: { available_amount: number; frozen_amount: number; currency: string };
features: Record<string, boolean>;
banners: Array<{ image_url?: string; title?: string }>;
banners: BannerData[];
notices: Array<{ title?: string; content?: string }>;
task_summary: {
today_count: number;
@@ -96,6 +128,13 @@
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 {
rank: number;
username: string;
@@ -108,9 +147,13 @@
const loading = ref(true);
const error = ref('');
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 currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
const bannerStyle = computed(() => {
const url = safeImage(home.value?.banners?.[0]?.image_url);
const url = safeImage(currentBanner.value?.image_url);
return {
backgroundImage: 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);
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() {
loading.value = true;
error.value = '';
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<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
noticeRequest,
]);
home.value = homeData;
leaderboard.value = rankData || [];
popupNotice.value = noticeData;
} catch (err) {
error.value = errorText(err);
} finally {
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);
onBeforeUnmount(() => {
if (bannerTimer) window.clearInterval(bannerTimer);
});
</script>
+91 -3
View File
@@ -103,20 +103,56 @@
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
>
</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>
</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>
</template>
<script setup lang="ts">
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 PageHeader from '../components/PageHeader.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 router = useRouter();
const isDetail = computed(() => Boolean(route.params.id));
const loading = ref(true);
const error = ref('');
@@ -124,6 +160,8 @@
const orders = ref<any[]>([]);
const detail = ref<any>();
const stats = ref<any>();
const showSubmitConfirm = ref(false);
const submitting = ref(false);
let listController: AbortController | undefined;
const tabs = [
{ label: '全部', value: 'all' },
@@ -149,6 +187,17 @@
failed: '失败',
};
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() {
listController?.abort();
const controller = new AbortController();
@@ -178,18 +227,57 @@
loading.value = true;
error.value = '';
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) {
error.value = errorText(err);
} finally {
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) {
status.value = value;
loadList();
}
function load() {
showSubmitConfirm.value = false;
if (isDetail.value) loadDetail();
else loadList();
}
+57 -28
View File
@@ -2,47 +2,76 @@
<section class="m-platform-intro">
<PageHeader title="平台介绍" />
<header class="m-platform-intro-head">
<span>OLONS PLATFORM</span>
<h2>平台简介</h2>
<p>{{ platformIntroLead }}</p>
<div><b>{{ platformIntroSections.length }}</b><small>服务条款</small><i></i><b>{{ platformFAQs.length }}</b><small>常见问题</small></div>
</header>
<AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load">
<template v-if="intro">
<header class="m-platform-intro-head">
<span>YUEXUANLI</span>
<h2>{{ intro.title }}</h2>
<p>{{ intro.summary }}</p>
<div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div>
</header>
<nav class="m-platform-intro-nav" aria-label="页面目录">
<button :class="{ active: activeSection === 'service-terms' }" @click="scrollToSection('service-terms')">服务条款</button>
<button :class="{ active: activeSection === 'common-questions' }" @click="scrollToSection('common-questions')">常见问题</button>
</nav>
<nav class="m-platform-intro-nav" aria-label="页面目录">
<button :class="{ active: activeSection === 'service-terms' }" @click="scrollToSection('service-terms')">服务条款</button>
<button :class="{ active: activeSection === 'common-questions' }" @click="scrollToSection('common-questions')">常见问题</button>
</nav>
<main>
<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>
<article v-for="section in platformIntroSections" :key="section.title">
<h3>{{ section.title }}</h3>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
</article>
</section>
<main>
<section id="service-terms" class="m-platform-document">
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>{{ intro.title }}</h2></div></div>
<article class="m-managed-rich-text" v-html="intro.body_html"></article>
</section>
<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>
<article v-for="item in platformFAQs" :key="item.title">
<h3>{{ item.title }}</h3>
<p v-for="paragraph in item.paragraphs" :key="paragraph">{{ paragraph }}</p>
</article>
</section>
</main>
<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>{{ faq?.title || '常见问题' }}</h2></div></div>
<article v-if="faq" class="m-managed-rich-text" v-html="faq.body_html"></article>
<article v-else><p>暂无常见问题。</p></article>
</section>
</main>
</template>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, ref } from '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 intro = ref<ManagedContent>();
const faq = ref<ManagedContent>();
const loading = ref(true);
const error = ref('');
function scrollToSection(id: string) {
activeSection.value = id;
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>
+4 -1
View File
@@ -45,11 +45,13 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request } from '../api';
const levels = ref<any[]>([]);
const router = useRouter();
const channels = ref<any[]>([]);
const loading = ref(true);
const error = ref('');
@@ -104,9 +106,10 @@
remark: `姓名:${form.name}`,
},
});
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
form.amount = '';
form.name = '';
await router.push({ name: 'MobileSupport' });
} catch (err) {
formError.value = errorText(err);
} finally {
+80 -34
View File
@@ -5,47 +5,93 @@
>
<PageHeader title="规则说明" />
<div class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
<span>充值赠送金</span>
<p><b>充值<strong>500元</strong></b><b>赠送<strong>20元</strong></b></p>
</div>
<div class="m-rules-table" role="table" aria-label="会员规则">
<div class="m-rules-row m-rules-head" role="row">
<b>充值金额</b><b>会员等级</b><b>佣金比例</b><b>每日代购</b><b>额外奖励</b><b>单次提现</b>
<AsyncState :loading="loading" :error="error" :empty="!levels.length" empty-text="暂无生效的会员等级" @retry="load">
<div v-if="entryLevel" class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
<span>{{ entryLevel.name }}等级配置</span>
<p><b>升级价格<strong>{{ yuan(entryLevel.upgrade_amount) }}</strong></b><b>赠送金额<strong>{{ yuan(entryLevel.gift_amount) }}</strong></b></p>
</div>
<div v-for="level in levels" :key="level.name" class="m-rules-row" role="row">
<span>{{ level.amount }}</span>
<span class="m-rule-level"><img :src="level.icon" alt="" />{{ level.name }}</span>
<span>{{ level.rate }}</span>
<span>{{ level.orders }}</span>
<span>{{ level.bonus }}</span>
<span>{{ level.withdrawal }}</span>
</div>
</div>
<section class="m-rules-notes">
<h2><span>03</span> 规则补充说明</h2>
<ul>
<li>达到对应单次充值门槛,赠送所列永久会员等级。</li>
<li>每日代购数量、额外奖励及提现额度以对应档位为准。</li>
<li>星耀、至尊档位原文佣金比例前后不一致,需核实。</li>
<li>奖励领取条件、实际结算及提现限制请以正式规则为准。</li>
</ul>
</section>
<div class="m-rules-table" role="table" aria-label="会员规则">
<div class="m-rules-row m-rules-head" role="row">
<b>升级价格</b><b>会员等级</b><b>订单佣金</b><b>每日接单</b><b>赠送金额</b><b>每日提现次数</b>
</div>
<div v-for="level in levels" :key="level.id" class="m-rules-row" role="row">
<span>≥ {{ yuan(level.upgrade_amount) }}</span>
<span class="m-rule-level"><img :src="level.icon_url || fallbackIcon(level.code)" alt="" />{{ level.name }}</span>
<span>{{ percent(level.commission_rate) }}</span>
<span>{{ level.daily_order_limit }}单</span>
<span>{{ yuan(level.gift_amount) }}</span>
<span>{{ level.daily_withdraw_limit }}次</span>
</div>
</div>
<section class="m-rules-notes">
<h2><span>03</span> {{ ruleContent?.title || '规则补充说明' }}</h2>
<div v-if="ruleContent" class="m-managed-rich-text" v-html="ruleContent.body_html"></div>
<ul v-else>
<li>本页展示后台当前生效的会员等级配置,后台调整后重新进入页面即可查看新规则。</li>
<li>订单佣金按订单金额 × 匹配时会员等级的佣金比例计算,实际金额以订单详情为准。</li>
<li>每日接单、每日提现均为次数限制;赠送金额为等级配置,实际入账以账户明细为准。</li>
</ul>
</section>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request } from '../api';
import { mobileAssets } from '../assets';
const levels = [
{ amount: '≥ 1888元', name: '白银', rate: '0.02', orders: '20单', bonus: '88元', withdrawal: '5000以下', icon: mobileAssets.levelSilver },
{ amount: '≥ 10000元', name: '黄金', rate: '0.022', orders: '30单', bonus: '188元', withdrawal: '20000以下', icon: mobileAssets.levelGold },
{ amount: '≥ 30000元', name: '铂金', rate: '0.03', orders: '40单', bonus: '388元', withdrawal: '30000以下', icon: mobileAssets.levelPlatinum },
{ amount: '≥ 50000元', name: '钻石', rate: '0.035', orders: '50单', bonus: '588元', withdrawal: '50000以下', icon: mobileAssets.levelDiamond },
{ amount: '≥ 80000元', name: '星耀', rate: '0.04', orders: '80单', bonus: '1888元', withdrawal: '100000以下', icon: mobileAssets.levelStar },
{ amount: '≥ 180000元', name: '至尊', rate: '0.05', orders: '100单', bonus: '2888元', withdrawal: '无限制', icon: mobileAssets.levelCrown },
];
interface MemberLevel {
id: number;
code: string;
name: string;
icon_url: string;
upgrade_amount: number;
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>
+310 -32
View File
@@ -37,64 +37,198 @@
<section v-else class="m-chat">
<PageHeader :title="current?.name || '在线客服'" />
<div class="m-chat-time">会话内容由客服系统提供</div>
<div class="m-chat-empty">
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
<div v-if="chatLoading" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>正在连接客服</h2>
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
<a
v-if="safeServiceUrl"
:href="safeServiceUrl"
target="_blank"
rel="noopener noreferrer"
class="m-primary-btn m-btn-link"
>进入客服会话</a
>
<p v-else class="m-form-error">客服会话地址尚未配置</p>
<p>首次连接会自动建立好友关系</p>
</div>
<div v-else-if="chatError" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>连接失败</h2>
<p class="m-form-error">{{ chatError }}</p>
<button class="m-primary-btn" @click="connectChat">重新连接</button>
</div>
<div v-else ref="messageList" class="m-chat-messages">
<div 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 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>
</template>
<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 { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
import {
ChatbubbleEllipsesOutline,
HappyOutline,
HeadsetOutline,
ImageOutline,
VideocamOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.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 { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
const route = useRoute();
const isChat = computed(() => Boolean(route.params.id));
const loading = ref(true);
const error = ref('');
const services = ref<any[]>([]);
const me = ref<any>();
const current = computed(
() =>
services.value.find((item) => String(item.id) === String(route.params.id)) ||
services.value[0]
);
const safeServiceUrl = computed(() => {
const value = current.value?.service_url;
if (!value) return '';
try {
const u = new URL(value);
return u.protocol === 'https:' ? u.href : '';
} catch {
return '';
}
});
const chatLoading = ref(false);
const chatError = ref('');
const messages = ref<ChatMessage[]>([]);
const historyBoundarySeq = ref(0);
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
const draft = ref('');
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() {
loading.value = true;
error.value = '';
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) {
error.value = errorText(err);
const message = errorText(err);
error.value = message;
if (isChat.value) chatError.value = message;
services.value = [];
} finally {
loading.value = false;
@@ -105,5 +239,149 @@
if (status === 'busy') 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);
onBeforeUnmount(disconnectChat);
</script>
+115 -7
View File
@@ -41,36 +41,79 @@
<span>加载中</span>
</div>
<n-modal v-model:show="showConfirm">
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
<n-modal
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>
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
<label>
<label v-if="hasTradePassword">
<span>支付密码</span>
<input
ref="tradePasswordInput"
v-model="form.tradePassword"
type="password"
name="trade-password"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位支付密码"
/>
</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>
<button class="m-primary-btn" :disabled="submitting">确认申请</button>
<button class="m-primary-btn" :disabled="submitting || !hasTradePassword">确认申请</button>
</form>
</n-modal>
</section>
</template>
<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 { ChevronForwardOutline } from '@vicons/ionicons5';
import { ChevronForwardOutline, CloseOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.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';
const router = useRouter();
@@ -82,8 +125,13 @@
const accounts = ref<any[]>([]);
const quote = ref<any>();
const showConfirm = ref(false);
const showTradePasswordSetup = ref(false);
const setupError = ref('');
const withdrawalKey = ref('');
const tradePasswordInput = ref<HTMLInputElement>();
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
const setupForm = reactive({ newTradePassword: '', loginPassword: '' });
const hasTradePassword = computed(() => me.value?.has_trade_password === true);
const selectedAccount = computed(() =>
accounts.value.find((item) => item.id === form.paymentAccountId)
);
@@ -101,6 +149,57 @@
function openAccountPicker() {
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() {
loading.value = true;
loadError.value = '';
@@ -138,6 +237,8 @@
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
});
withdrawalKey.value = idempotencyKey();
form.tradePassword = '';
showTradePasswordSetup.value = false;
showConfirm.value = true;
} catch (err) {
actionError.value = errorText(err);
@@ -147,6 +248,10 @@
}
async function submitWithdrawal() {
if (!quote.value || submitting.value) return;
if (!hasTradePassword.value) {
actionError.value = '请先设置支付密码';
return;
}
if (!/^\d{6}$/.test(form.tradePassword)) {
actionError.value = '请输入 6 位支付密码';
return;
@@ -170,6 +275,9 @@
await router.replace('/app/profile/records?tab=withdrawal');
} catch (err) {
actionError.value = errorText(err);
if (err instanceof MobileApiError && err.reason === 'TRADE_PASSWORD_NOT_SET') {
me.value = { ...me.value, has_trade_password: false };
}
} finally {
submitting.value = false;
}
+21 -1
View File
@@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [
path: 'profile/bank-card',
name: 'MobileBankCard',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '添加银行卡', form: 'bank' },
meta: { title: '绑定信息', form: 'bank' },
},
{
path: 'profile/withdrawal',
@@ -187,6 +187,26 @@ const router = createRouter({
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) => {
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
});
+4
View File
@@ -0,0 +1,4 @@
declare module 'tinode-sdk' {
export const Drafty: any;
export const Tinode: any;
}
+4
View File
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
},
},
build: {