Compare commits
55
Commits
main
..
dd729cb679
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd729cb679 | ||
|
|
8f3a97a54d | ||
|
|
2d411e1b44 | ||
|
|
3f15082ebc | ||
|
|
81e3cf1d03 | ||
|
|
c13fb72849 | ||
|
|
8d32376de8 | ||
|
|
71b49ddcf7 | ||
|
|
d2e70efd9d | ||
|
|
47a10c93be | ||
|
|
1bcce3cdde | ||
|
|
192ab342f6 | ||
|
|
9cb8c4d93a | ||
|
|
37e89a6457 | ||
|
|
97a43a6c96 | ||
|
|
a85393b2b8 | ||
|
|
75f811bb9f | ||
|
|
8773991015 | ||
|
|
5bf68727fa | ||
|
|
4f69dc302b | ||
|
|
bc3537bd9e | ||
|
|
86c5486cc4 | ||
|
|
c8413f82d9 | ||
|
|
0a3a406089 | ||
|
|
a39002b49d | ||
|
|
53accc3e3f | ||
|
|
06e3f0b489 | ||
|
|
ef2967b056 | ||
|
|
dacac13f88 | ||
|
|
5c805c7b09 | ||
|
|
7099a33cde | ||
|
|
fe7d4ccb3e | ||
|
|
9cfeb5f2c7 | ||
|
|
dd5986d57a | ||
|
|
ba0101297d | ||
|
|
f027764c47 | ||
|
|
ba6fd7f1b6 | ||
|
|
228e3a54fd | ||
|
|
fc246c3086 | ||
|
|
903a77f2c5 | ||
|
|
5789b85663 | ||
|
|
b83390bccd | ||
|
|
250a305fbf | ||
|
|
1bedf7e11c | ||
|
|
21c36abf58 | ||
|
|
cf08f7ceeb | ||
|
|
1d994c9302 | ||
|
|
bdb704832f | ||
|
|
edb9a6b2bb | ||
|
|
989e365492 | ||
|
|
c1c11560de | ||
|
|
8b5c5e3df5 | ||
|
|
4c964ee3ba | ||
|
|
982e71f093 | ||
|
|
482f9b80d9 |
@@ -0,0 +1,37 @@
|
||||
name: Deploy im-workbench (k3s test cluster)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 确定镜像tag
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=test
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 登录镜像仓库
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
docker build -t git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }} .
|
||||
docker push git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }}
|
||||
|
||||
- name: 部署到k3s测试集群
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl rollout restart deployment/im-workbench -n workbench-system
|
||||
kubectl rollout status deployment/im-workbench -n workbench-system --timeout=120s
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
@@ -24,6 +25,7 @@ go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
.idea/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
@@ -162,4 +164,3 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# ---- Stage 1: build the Vue frontend ----
|
||||
FROM node:22-alpine AS web-builder
|
||||
|
||||
WORKDIR /web
|
||||
RUN corepack enable
|
||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
# ---- Stage 2: build the Go backend ----
|
||||
FROM golang:1.26 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
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
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
||||
COPY --from=builder /out/im_workbench /app/im_workbench
|
||||
COPY configs /app/configs
|
||||
COPY --from=web-builder /web/dist /app/web/dist
|
||||
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
|
||||
EXPOSE 6061
|
||||
ENTRYPOINT ["/app/im_workbench"]
|
||||
@@ -0,0 +1,16 @@
|
||||
.PHONY: build test run web-install web-build
|
||||
|
||||
build:
|
||||
go build -o bin/im_workbench ./cmd/workbench
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
run:
|
||||
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
||||
|
||||
web-install:
|
||||
cd web && pnpm install --frozen-lockfile
|
||||
|
||||
web-build:
|
||||
cd web && pnpm run build
|
||||
@@ -1,2 +1,82 @@
|
||||
# olons_server
|
||||
# Workbench
|
||||
|
||||
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
||||
|
||||
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
||||
|
||||
## 当前接口
|
||||
|
||||
- `POST /v1/auth/register`
|
||||
- `POST /v1/auth/login`
|
||||
- `POST /v1/auth/logout`
|
||||
- `GET /v1/me`
|
||||
- `PATCH /v1/me/avatar`
|
||||
- `PUT /v1/me/password`
|
||||
- `PUT /v1/me/trade-password`
|
||||
- `GET /v1/home`
|
||||
- `GET /v1/banners`
|
||||
- `GET /v1/contents/{content_key}`
|
||||
- `GET /v1/member-levels`
|
||||
- `GET /v1/task-halls`
|
||||
- `GET /v1/tasks/eligibility`
|
||||
- `GET /v1/tasks/stats`
|
||||
- `POST /v1/task-orders/claim`
|
||||
- `GET /v1/task-orders`
|
||||
- `GET /v1/task-orders/{order_id}`
|
||||
- `POST /v1/task-orders/{order_id}/submissions`
|
||||
- `GET /v1/manual-products`
|
||||
- `GET /v1/manual-products/{product_id}`
|
||||
- `GET /v1/payment-accounts`
|
||||
- `POST /v1/payment-accounts`
|
||||
- `POST /v1/withdrawal-orders/quote`
|
||||
- `POST /v1/withdrawal-orders`
|
||||
- `GET /v1/withdrawal-orders`
|
||||
- `GET /v1/withdrawal-orders/{order_id}`
|
||||
- `GET /healthz`
|
||||
- `GET /metrics`
|
||||
|
||||
注册使用 `hg_app_member` 作为账户主档,并在同一事务中创建 `hg_app_member_profile` 和 `hg_wallet_account` 记录。登录按完整 `mobile` 查找会员,校验 bcrypt 密码后在 Redis 中创建独立会话。
|
||||
|
||||
## 启动配置
|
||||
|
||||
核心环境变量:
|
||||
|
||||
```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,多个以逗号分隔>'
|
||||
```
|
||||
|
||||
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
||||
|
||||
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
||||
|
||||
```bash
|
||||
make web-install
|
||||
make web-build
|
||||
make run
|
||||
```
|
||||
|
||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
|
||||
|
||||
## 请求头
|
||||
|
||||
```http
|
||||
X-API-Key: <api key>
|
||||
X-Request-ID: <request id>
|
||||
X-Device-ID: <optional device id>
|
||||
```
|
||||
|
||||
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
前后端完整对接契约见 [`docs/移动端任务与账户平台前后端对接文档.md`](docs/移动端任务与账户平台前后端对接文档.md),已实现的注册登录简版契约见 [`docs/注册登录对接文档.md`](docs/注册登录对接文档.md)。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/app"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
server, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("init app: %v", err)
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: server.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("workbench listening on %s", cfg.Listen)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
stopCh := make(chan os.Signal, 1)
|
||||
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
log.Fatalf("serve: %v", err)
|
||||
case <-stopCh:
|
||||
log.Printf("shutdown requested")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"listen": ":6061",
|
||||
"frontend_dir": "web/dist",
|
||||
"mysql": {
|
||||
"dsn": "root:root@tcp(192.168.1.135:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
"max_open_conns": 15,
|
||||
"max_idle_conns": 5
|
||||
},
|
||||
"redis": {
|
||||
"addr": "127.0.0.1:6379",
|
||||
"db": 0
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"allow_dev_header_auth": true,
|
||||
"required_api_key": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"listen": ":6061",
|
||||
"frontend_dir": "web/dist",
|
||||
"mysql": {
|
||||
"dsn": "",
|
||||
"max_open_conns": 15,
|
||||
"max_idle_conns": 5
|
||||
},
|
||||
"redis": {
|
||||
"addr": "",
|
||||
"db": 0
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"required_api_key": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# 注册登录对接文档
|
||||
|
||||
接口公共前缀为 `/v1`,旧的应用名路径前缀不再提供兼容路由。
|
||||
|
||||
## 1 数据模型
|
||||
|
||||
账户以 `olons_admin.hg_app_member` 为主档:
|
||||
|
||||
- 手机号完整写入 `mobile`,同一租户内唯一,不再保存加密、哈希或脱敏辅助列。
|
||||
- 登录密码使用 bcrypt 写入 `password_hash`。
|
||||
- 推荐码通过上级会员的 `invite_code` 建立 `parent_id`、`level` 和 `tree`。
|
||||
- 注册事务同时创建 `hg_app_member_profile` 和 CNY `hg_wallet_account`。
|
||||
|
||||
## 2 公共 Header
|
||||
|
||||
```http
|
||||
X-API-Key: <api key>
|
||||
X-Request-ID: <request id>
|
||||
X-Device-ID: <optional device id>
|
||||
```
|
||||
|
||||
## 3 注册
|
||||
|
||||
```http
|
||||
POST /v1/auth/register
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"password": "Secret123",
|
||||
"referral_code": "ABCD2345",
|
||||
"privacy_accepted": true
|
||||
}
|
||||
```
|
||||
|
||||
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
|
||||
|
||||
## 4 登录
|
||||
|
||||
```http
|
||||
POST /v1/auth/login
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"phone": "13800138000",
|
||||
"password": "Secret123"
|
||||
}
|
||||
```
|
||||
|
||||
界面的“记住密码”由客户端安全存储,不作为接口字段上传。
|
||||
|
||||
## 5 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"member_id": 9,
|
||||
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"invite_code": "EFGH6789",
|
||||
"token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "2026-10-23T10:00:00Z"
|
||||
},
|
||||
"request_id": "uuid",
|
||||
"server_time": "2026-10-09T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Token 是 32 字节随机会话凭证的 Base64URL 编码。Redis 只使用 Token SHA-256 摘要作为 Key,不存储原始 Token。
|
||||
|
||||
## 6 退出登录
|
||||
|
||||
```http
|
||||
POST /v1/auth/logout
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
退出成功后删除当前 Redis 会话,`data` 为 `null`。缺少、格式错误或已失效的 Token 返回 HTTP 401 和 `AUTH_REQUIRED`。
|
||||
|
||||
## 7 错误 reason
|
||||
|
||||
| reason | HTTP | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
|
||||
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
|
||||
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
|
||||
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
|
||||
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
|
||||
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
|
||||
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
|
||||
| `AUTH_REQUIRED` | 401 | 缺少或无效的登录会话 |
|
||||
| `RATE_LIMITED` | 429 | 15 分钟内登录失败次数过多 |
|
||||
| `SERVICE_UNAVAILABLE` | 503 | Redis 或敏感数据密钥未正确配置 |
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
@@ -0,0 +1,368 @@
|
||||
# 移动端任务与账户平台需求文档
|
||||
|
||||
**参考站点功能拆解与重构建议**
|
||||
|
||||
目标:在不复制品牌素材、敏感数据与高风险业务表述的前提下,复刻参考站点的信息架构和交互骨架,并形成可开发、可验收、可审计的产品需求基线。
|
||||
|
||||
主要读者:产品负责人、交互设计师、前端开发、后端开发、测试、风控与运营。
|
||||
|
||||
观察日期:2026-10-07 参考环境:移动端网页 文档版本:V1.0
|
||||
|
||||
结论:参考站点由账户、任务订单、余额台账、储入取出、会员等级、内容展示和在线客服七个域构成。建议复刻页面结构与业务闭环,但重写资金、隐私、安全和合规机制。
|
||||
|
||||
# 1 文档范围与方法
|
||||
|
||||
本需求基于登录后的页面逐项浏览、按钮与导航行为检查、页面状态观察,以及浏览器中可见网络请求和前端资源分析。本文把结论分为三类:实测行为、前端可见实现、建议需求。敏感手机号、邀请码、银行卡等信息已脱敏,不作为原型内容。
|
||||
|
||||
## 1.1 已覆盖范围
|
||||
|
||||
- 登录页现状、注册页字段、会话与路由保护。
|
||||
- 首页、荣誉榜、会员等级、平台简介、规则说明、代理合作、公司资质。
|
||||
- 订单记录筛选、领取中心、订单资金变化、手工商城。
|
||||
- 储入、取出、收款信息绑定、储入记录、取出记录、账户明细。
|
||||
- 个人信息、头像上传、登录密码、交易密码、通知记录、在线客服。
|
||||
- 可观察接口、响应结构、关键数据字段与前端安全实现。
|
||||
|
||||
## 1.2 未执行操作
|
||||
|
||||
为避免生成真实订单、变更资金、上传个人文件、修改密码或退出当前会话,本文未点击自动领取、订单提交、储入提交、取出确认、收款信息提交、头像上传、密码提交和退出登录。对应需求由页面字段、前端接口定义与上下游数据状态推导,并标记为待联调确认。
|
||||
|
||||
# 2 产品定位与角色
|
||||
|
||||
产品可定位为移动端会员任务与佣金账户平台。用户通过注册、储入余额、领取任务订单、完成任务、获得本金返还与佣金,并可查看台账或发起取出。平台通过等级、任务数量和费率管理用户权益,通过客服处理储入、异常订单和账户问题。
|
||||
|
||||
| **角色** | **主要目标** | **关键权限** |
|
||||
| --- | --- | --- |
|
||||
| 游客 | 了解平台并注册 | 登录、注册、查看隐私政策 |
|
||||
| 会员 | 领取任务并查看收益 | 首页、任务、订单、余额、客服、个人设置 |
|
||||
| 高级会员 | 按等级获得更多任务和更高费率 | 等级权益、额度与次数受配置控制 |
|
||||
| 客服 | 处理储入、取出、异常任务 | 会话、工单、用户与交易查询 |
|
||||
| 运营 | 管理内容、等级、任务和公告 | 配置、发布、审核、数据看板 |
|
||||
| 财务与风控 | 审核资金和异常行为 | 充值审核、提现审核、台账、风控规则 |
|
||||
| 管理员 | 维护权限与系统参数 | RBAC、审计日志、系统配置 |
|
||||
|
||||
# 3 信息架构与页面路由
|
||||
|
||||
| **一级模块** | **页面或路由** | **核心内容** | **入口** |
|
||||
| --- | --- | --- | --- |
|
||||
| 认证 | /login /register | 手机号登录、账户名、推荐码、验证码 | 未登录访问 |
|
||||
| 首页 | /home | 活动图、快捷入口、余额、荣誉榜、会员等级、公司介绍 | 底部首页 |
|
||||
| 任务 | /receive /orderDetail | 领取、等待、任务详情、提交完成 | 底部领取 |
|
||||
| 记录 | 记录页 | 全部、待处理、已完成订单 | 底部记录 |
|
||||
| 手工 | /mall | 手工任务卡片并引导客服 | 首页快捷入口 |
|
||||
| 资金 | /recharge /withdrawal | 储入、取出、余额、交易密码 | 首页与个人中心 |
|
||||
| 账户 | /mine /personalInfo | 用户资料、信用分、邀请码、设置 | 底部我的 |
|
||||
| 明细 | /depositRecord /withdrawalRecord /bill | 储入、取出、余额台账 | 我的 |
|
||||
| 客服 | /customerService /iframeCustom | 客服列表与第三方会话 | 底部客服 |
|
||||
| 内容 | /html/{id} | 平台简介、规则、代理、资质 | 首页公司简介 |
|
||||
|
||||
## 3.1 建议导航原则
|
||||
|
||||
- 底部五栏固定为首页、记录、领取、客服、我的;详情页隐藏底部栏并保留明确返回。
|
||||
- 资金页面必须显示币种、可用余额、冻结余额、预计到账和手续费,不使用含义不明的“人民币编码”。
|
||||
- 所有金额变动都能跳转至同一业务单据,并在台账中保持可解释、可追溯。
|
||||
- 客服 iframe 需明确第三方来源、隐私边界和会话状态,失败时提供备用联系方式。
|
||||
|
||||
# 4 核心业务闭环
|
||||
|
||||
## 4.1 任务订单闭环
|
||||
|
||||
1. 用户进入领取中心,系统校验登录态、营业时间、会员等级、最低可用余额、未完成任务与风控状态。
|
||||
1. 用户点击领取后,系统创建或匹配一笔任务订单,返回任务编号、商品、金额、佣金、截止时间与状态。
|
||||
1. 任务确认时,台账先记录交易扣款;完成后记录本金返还和佣金入账。参考数据中同一业务单号出现 -298、+298、+14.9 三条流水。
|
||||
1. 订单必须支持待领取、匹配中、待处理、处理中、已完成、超时、冻结、取消与失败,并记录每次状态变化。
|
||||
1. 任何补差额、冻结或信用分变化都必须有明确原因、复核入口和申诉渠道;不得依赖口头客服指令。
|
||||
|
||||
## 4.2 储入与取出闭环
|
||||
|
||||
储入页面按等级展示金额、赠送额、每日单数和佣金比例,用户填写金额与姓名后提交。参考站点还要求联系客服获取收款方式。新系统应改为平台内生成可核验的支付订单,禁止客服通过聊天发送临时个人账户。
|
||||
|
||||
取出前需绑定姓名、银行名、银行账户与收款码,随后输入金额和交易密码。新系统应只保留与实际支付通道必要的字段,敏感数据加密存储,收款码上传须做病毒扫描、内容校验与访问控制。
|
||||
|
||||
## 4.3 会员与代理闭环
|
||||
|
||||
参考站点设置普通、白银、黄金、铂金、钻石、星耀、至尊七档,充值门槛、赠送额、每日单数和佣金比例逐级上升,并存在三级推荐奖励。建议将等级规则做成版本化配置,禁止前端写死;代理奖励必须在合规评估通过后再启用。
|
||||
|
||||
# 5 页面级功能需求
|
||||
|
||||
## 5.1 登录与注册
|
||||
|
||||
| **页面** | **字段与控件** | **校验与行为** | **验收要点** |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录 | 手机号、密码、记住密码、登录、去注册、隐私政策 | 手机号格式;密码非空;错误次数限制;登录成功进入首页 | 不在本地明文保存密码;支持会话过期提示 |
|
||||
| 注册 | 账户名、11 位手机号、密码、推荐码、图片验证码、隐私政策 | 账户名必填;手机号唯一;验证码校验;推荐码可选或按运营配置 | 提交前明确同意协议;防批量注册;注册成功自动登录或返回登录 |
|
||||
| 会话 | Authorization 令牌 | 401 清除会话并返回登录;403 显示可解释错误 | 支持刷新、撤销、设备管理与退出所有设备 |
|
||||
|
||||
## 5.2 首页
|
||||
|
||||
- 展示轮播图或单图运营位,支持后台排序、上下线、跳转类型和有效期。
|
||||
- 快捷入口为领取、储入、取出、手工;按后台开关显示,关闭时给出原因而不是静默失效。
|
||||
- 账户卡展示昵称、可用余额与币种;点击可进入账户明细。
|
||||
- 荣誉榜为脱敏昵称、积分和结果状态,要求数据真实性标识、刷新频率与隐私策略。
|
||||
- 会员等级展示当前等级、费率和下一等级条件;等级图不得只是一张不可访问的图片。
|
||||
- 公司简介包括平台简介、规则说明、代理合作和公司资质,内容由 CMS 管理并保留版本。
|
||||
|
||||
## 5.3 记录与订单详情
|
||||
|
||||
记录页显示可用资产,提供全部、待处理、已完成三种筛选。列表项包括创建时间、商品图、商品名、商品价格、佣金与状态。分页默认 10 条,支持下拉刷新、空状态、加载失败重试和详情跳转。
|
||||
|
||||
实测发现先切换待处理、再切换已完成时曾短暂出现空列表,而重新加载全部后可见已完成记录。验收时需覆盖筛选参数、缓存状态和并发请求覆盖问题。
|
||||
|
||||
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
||||
| --- | --- | --- | --- |
|
||||
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
||||
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
|
||||
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
|
||||
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
|
||||
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
||||
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
||||
|
||||
## 5.4 领取中心
|
||||
|
||||
领取中心包含会员等级、佣金费率、主领取按钮、匹配进度、今日战果和规则说明。点击主按钮前需完成资格预检;按钮点击后立即进入不可重复提交的 loading 状态;匹配成功跳转订单详情,失败显示可恢复原因。
|
||||
|
||||
- 预检项:营业时间、账户状态、信用或风控状态、最低余额、当日次数、未完成订单、等级商品范围。
|
||||
- 统计项:总资产、昨日佣金、今日佣金、今日领取数;所有统计口径需要后台统一定义。
|
||||
- 规则文案需要 CMS 管理,禁止把风控与资金规则硬编码在页面。
|
||||
|
||||
## 5.5 手工商城
|
||||
|
||||
页面以双列卡片展示图片、任务名称、份数、工资与已领取数;点击卡片实测直接进入客服页。建议增加任务详情、适用人群、交付方式、预计周期、库存、审核标准和风险提示,再由用户主动发起咨询。
|
||||
|
||||
参考站点一条商品名称中可见脚本标签文本,说明内容过滤不足。新系统必须对运营内容做 HTML 转义、富文本白名单、图片域名白名单和持久型 XSS 检测。
|
||||
|
||||
## 5.6 客服
|
||||
|
||||
客服页展示多个客服入口与工作时间,点击后在站内 iframe 打开第三方聊天。聊天页含历史会话、系统消息、文本框、图片与视频上传。需求上应增加服务状态、排队人数、会话编号、隐私声明、附件大小与类型限制、敏感信息提醒和会话评价。
|
||||
|
||||
## 5.7 我的与个人信息
|
||||
|
||||
| **功能** | **需求** | **边界或风险** |
|
||||
| --- | --- | --- |
|
||||
| 资料概览 | 头像、昵称、邀请码、会员等级、信用状态、余额 | 手机号等敏感字段默认脱敏 |
|
||||
| 储入与取出 | 提供余额入口与状态提示 | 资金操作必须二次确认与风控 |
|
||||
| 收款信息 | 姓名、银行、账户、可选支付通道 | 加密、最小化收集、修改留痕 |
|
||||
| 头像 | 上传、裁剪、预览、替换 | 文件扫描、大小和格式校验 |
|
||||
| 密码 | 旧密码、新密码、确认密码 | 强度、频控、成功后令牌失效 |
|
||||
| 交易密码 | 独立于登录密码 | 不得用无盐 MD5;失败次数锁定 |
|
||||
| 通知 | 公告列表、未读、已读、详情 | 重要规则变更需留存阅读证据 |
|
||||
| 退出 | 清除令牌和本地用户数据 | 保留确认,避免误触 |
|
||||
|
||||
## 5.8 储入
|
||||
|
||||
页面展示等级规则表和金额、姓名输入框。参考规则为普通 500、白银 1888、黄金 10000、铂金 30000、钻石 50000、星耀 88888、至尊 188888;费率从 1.8% 到 5%。规则说明和表格存在个别口径差异,必须以后台版本化配置为唯一事实源。
|
||||
|
||||
- 提交前展示收款主体、订单号、有效期、金额、手续费和风险提示。
|
||||
- 储入订单状态:待支付、待审核、成功、失败、过期、取消;失败必须提供原因。
|
||||
- 若需要上传付款凭证,必须经过用户明确选择,并限制为图片或 PDF。
|
||||
|
||||
## 5.9 取出与收款绑定
|
||||
|
||||
取出页面展示余额、金额、已绑定收款信息摘要和交易密码。若未绑定,弹窗提示并引导至绑定页。绑定页实测字段为姓名、银行名、银行账户、人民币编码、支付宝收款码和微信收款码。建议删除含义不清或非必要字段,并根据支付渠道动态展示。
|
||||
|
||||
- 金额校验:大于 0、不超过可用余额、符合单笔最小值和最大值,并显示手续费与到账金额。
|
||||
- 状态:待审核、审核中、处理中、成功、失败、驳回、取消;用户可查看原因和预计时间。
|
||||
- 交易密码只在提交时使用;服务端采用强哈希校验并配合频率限制、设备风控和二次验证。
|
||||
|
||||
## 5.10 账户明细与记录
|
||||
|
||||
账户明细需按业务单号聚合展示变动类型、变动金额、变动前余额、变动后余额、时间、状态和关联单据。储入记录显示订单号、状态、金额、创建时间;取出记录字段同构,并增加手续费、到账金额与失败原因。
|
||||
|
||||
# 6 数据模型与状态机
|
||||
|
||||
| **实体** | **关键字段** | **说明** |
|
||||
| --- | --- | --- |
|
||||
| User | id, username, mobile, avatar, status, inviteCode | 用户主档,手机号单独加密与索引 |
|
||||
| Membership | levelId, rate, dailyLimit, withdrawLimits, effectiveAt | 版本化等级权益 |
|
||||
| Wallet | available, frozen, currency, version | 使用乐观锁或账户序列保证一致性 |
|
||||
| LedgerEntry | bizNo, type, direction, amount, before, after | 不可变台账,支持幂等与冲正 |
|
||||
| TaskOrder | orderNo, productId, amount, commission, status, deadline | 任务生命周期 |
|
||||
| DepositOrder | depositNo, amount, channel, status, proofUrl | 储入生命周期 |
|
||||
| WithdrawalOrder | withdrawNo, amount, fee, netAmount, status | 取出生命周期 |
|
||||
| PayoutProfile | realName, bankName, maskedAccount, qrAssetIds | 敏感字段加密与脱敏 |
|
||||
| Content | type, title, body, version, status, publishedAt | 公告、规则与公司资料 |
|
||||
| SupportSession | sessionNo, provider, status, openedAt, closedAt | 第三方客服映射 |
|
||||
| AuditLog | actor, action, target, before, after, ip, device | 资金与权限操作强制留痕 |
|
||||
|
||||
## 6.1 幂等与一致性
|
||||
|
||||
- 领取、提交订单、储入回调、取出申请和密码修改都必须使用幂等键。
|
||||
- 钱包余额只能由服务端台账事务修改;前端显示值不参与结算。
|
||||
- 订单状态转换由服务端状态机控制,不允许客户端任意设置。
|
||||
- 重复回调、网络重试和并发点击不得重复扣款、返款或发佣金。
|
||||
- 所有涉及金额的接口返回业务单号和最新余额版本。
|
||||
|
||||
# 7 接口需求
|
||||
|
||||
下表分为实测请求与前端资源中可见接口。实测接口在浏览器中返回 HTTP 200,响应外层为 Base64 字符串,解码后为 JSON,统一包含 success、code、data、msg。建议新系统直接使用标准 JSON,不把 Base64 当作安全措施。
|
||||
|
||||
| **方法** | **接口** | **用途** | **主要请求或返回字段** | **证据** |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | /wap-api/doLogin | 登录 | username, password;token, users | 前端实现与已发生请求 |
|
||||
| POST | /wap-api/doRegister | 注册 | nickName, username, password, inviteCode, code, uuid | 前端实现 |
|
||||
| GET | /wap-api/captchaImage | 图片验证码 | uuid, img, captchaBol | 前端实现 |
|
||||
| GET | /wap-api/profile | 用户概要 | tel, username, headpic, inviteCode, level, levelRate, balance, xyf | 实测 |
|
||||
| GET | /wap-api/banner/list | 首页横幅 | 图片地址数组 | 实测 |
|
||||
| GET | /wap-api/home | 首页开关与内容编号 | SHOP_STATUS, TASK, POP_MESSAGE, 内容 ID | 实测 |
|
||||
| GET | /wap-api/user/data/home | 荣誉榜 | name, avatar, balance | 实测 |
|
||||
| GET | /wap-api/ctrl/level-list | 等级配置 | name, orderNum, num, giveNum, bili, 提现限制 | 实测 |
|
||||
| GET | /wap-api/support | 客服列表 | qq, url, workTime, 分页 | 实测 |
|
||||
| GET | /wap-api/convey/page | 订单分页 | pageNo, pageSize, status;订单与商品字段 | 实测 |
|
||||
| POST | /wap-api/convey/order | 领取或匹配订单 | 无显式参数;返回订单 | 前端实现,未执行 |
|
||||
| POST | /wap-api/convey/order/submit/{id} | 提交任务 | 订单 id | 前端实现,未执行 |
|
||||
| GET | /wap-api/convey/count | 领取统计 | balance, countToday, countNumToday, countNumYesterday, content | 实测 |
|
||||
| GET | /wap-api/convey/unpay | 未完成订单检查 | djs 等状态 | 实测 |
|
||||
| GET | /wap-api/product/page | 手工商品分页 | 分页条件;商品卡片 | 前端实现 |
|
||||
| GET | /wap-api/platform-config/trade-wait-time | 任务等待时间 | 秒数或分钟数 | 实测 |
|
||||
| GET | /wap-api/bank/current/info | 当前收款信息 | 收款资料摘要 | 前端实现 |
|
||||
| POST | /wap-api/bank/save | 保存收款信息 | 姓名、银行、账户、收款码 | 前端实现,未执行 |
|
||||
| POST | /wap-api/recharge/recharge | 创建储入申请 | amount, name 等 | 前端实现,未执行 |
|
||||
| GET | /wap-api/recharge/page | 储入记录分页 | 分页与状态 | 前端实现 |
|
||||
| POST | /wap-api/deposit/do_deposit | 创建取出申请 | amount, paypassword 等 | 前端实现,未执行 |
|
||||
| GET | /wap-api/deposit/page | 取出记录分页 | 分页与状态 | 前端实现 |
|
||||
| GET | /wap-api/balance-log/page | 账户流水 | 分页;类型、金额、业务单号 | 前端实现与页面实测 |
|
||||
| POST | /wap-api/users/setPwd | 修改登录密码 | oldPwd, newPwd | 前端实现,未执行 |
|
||||
| POST | /wap-api/users/setPwd2 | 修改交易密码 | oldPwd, newPwd | 前端实现,未执行 |
|
||||
| POST | /wap-api/local/upload | 文件上传 | multipart file | 前端实现,未执行 |
|
||||
| GET/POST | /wap-api/user-tc/select /read/{id} /page | 弹窗通知 | 公告、已读、分页 | 实测与前端实现 |
|
||||
|
||||
## 7.1 新系统接口规范
|
||||
|
||||
- 统一使用 HTTPS、JSON、明确的 HTTP 状态码、traceId、错误码和可本地化 message。
|
||||
- Authorization 使用短期访问令牌与可撤销刷新令牌;敏感操作增加一次性挑战或二次验证。
|
||||
- 列表接口统一 page、pageSize、sort、status,返回 items、total、hasNext。
|
||||
- 金额使用最小货币单位整数或高精度 Decimal,禁止浮点结算。
|
||||
- 上传接口先申请受限上传凭证,服务端扫描后再绑定业务。
|
||||
- 所有写接口要求 Idempotency-Key、设备信息与审计上下文。
|
||||
|
||||
# 8 管理后台需求
|
||||
|
||||
| **模块** | **主要能力** | **审批与审计** |
|
||||
| --- | --- | --- |
|
||||
| 用户管理 | 查询、状态、等级、信用、设备、邀请关系 | 冻结与解冻需理由和双人复核 |
|
||||
| 等级配置 | 门槛、赠送、次数、费率、提现限制、商品范围 | 版本化发布,可回滚 |
|
||||
| 任务商品 | 商品、图片、金额、佣金、库存、上下线 | 内容过滤和审核流 |
|
||||
| 任务订单 | 状态、倒计时、资金流水、异常处理 | 人工改状态必须双人审批 |
|
||||
| 储入管理 | 支付订单、回调、凭证、审核、对账 | 每日对账与差错处理 |
|
||||
| 取出管理 | 审核、通道、手续费、失败重试 | 大额与异常规则 |
|
||||
| 内容管理 | 横幅、规则、简介、资质、公告 | 预览、定时、版本与发布人 |
|
||||
| 客服管理 | 客服入口、工作时间、会话映射、工单 | 第三方访问最小权限 |
|
||||
| 风控 | 设备、IP、频次、关联账户、异常金额 | 命中规则可解释并可申诉 |
|
||||
| 权限审计 | RBAC、操作日志、导出日志 | 关键权限定期复核 |
|
||||
|
||||
# 9 非功能需求
|
||||
|
||||
## 9.1 安全
|
||||
|
||||
- 密码使用 Argon2id 或 bcrypt 加盐存储。参考站点在浏览器端对密码做 MD5,这不能替代服务端强哈希。
|
||||
- 输入与富文本做上下文相关转义;运营内容采用白名单;CSP 禁止任意外部脚本。
|
||||
- 银行卡和手机号加密存储,日志与埋点禁止记录完整值;展示统一脱敏。
|
||||
- 接口启用 CSRF 或同站策略、速率限制、风控挑战、签名回调和重放保护。
|
||||
- 第三方客服 iframe 设置最小 sandbox 权限、allowlist 域名和清晰的数据传输告知。
|
||||
- 上传文件限制 MIME、扩展名、体积与像素,执行恶意内容扫描,使用隔离存储和短期访问 URL。
|
||||
|
||||
## 9.2 性能与可用性
|
||||
|
||||
- 首屏 LCP 目标小于 2.5 秒,核心 API P95 小于 800 毫秒,写操作 P95 小于 1.5 秒。
|
||||
- 按钮防抖、请求取消、重试与离线提示;长列表分页和图片懒加载。
|
||||
- 资金写入接口可用性目标 99.95%,客服与内容接口目标 99.9%。
|
||||
- 客户端时间仅用于展示,截止时间与状态以服务端为准。
|
||||
|
||||
## 9.3 可访问性与国际化
|
||||
|
||||
按钮和导航必须有语义标签与至少 44×44 像素触控区域;颜色不作为唯一状态标识;支持字体放大。简体、繁体和术语需统一,避免同一页面混用“储入/充值”“取出/提现”“领取/抢单”。
|
||||
|
||||
# 10 合规与业务风险
|
||||
|
||||
参考站点涉及先储入、任务扣款、返本付佣、推荐奖励、信用分和提现限制。此类模式在不同司法辖区可能触及支付、消费者保护、反洗钱、网络营销或其他监管要求。上线前需由法务和持牌支付机构评估,不应直接复制其资金规则、免责条款或客服收款模式。
|
||||
|
||||
| **风险** | **参考站点表现** | **新系统要求** | **优先级** |
|
||||
| --- | --- | --- | --- |
|
||||
| 资金真实性 | 客服提供收款方式,失败责任不清 | 使用合规支付通道、订单化收款与自动对账 | P0 |
|
||||
| 诱导与不透明 | 高等级对应更高储入和收益 | 明确费用、风险、无保证收益,禁止误导 | P0 |
|
||||
| 推荐奖励 | 三级佣金 16% 8% 4% | 法务审查后决定是否启用 | P0 |
|
||||
| 账户冻结 | 规则含冻结、补差额、信用分 | 冻结需合法依据、通知、申诉和复核 | P0 |
|
||||
| 内容安全 | 商品名出现脚本标签文本 | CMS 白名单与持久型 XSS 防护 | P0 |
|
||||
| 密码安全 | 前端无盐 MD5 | 服务端强哈希、MFA、频控 | P0 |
|
||||
| 隐私 | 收集银行账户和收款码 | 最小化、加密、授权、删除与访问审计 | P0 |
|
||||
| 数据一致性 | 筛选短暂空列表、规则口径差异 | 单一配置源、请求竞态处理、自动化测试 | P1 |
|
||||
|
||||
# 11 埋点与运营指标
|
||||
|
||||
| **事件** | **触发时机** | **关键属性** |
|
||||
| --- | --- | --- |
|
||||
| auth_login_result | 登录成功或失败 | result, errorCode, deviceRisk |
|
||||
| home_shortcut_click | 点击首页快捷入口 | target, userLevel |
|
||||
| order_claim_click | 点击领取 | eligibilityResult, balanceBucket, level |
|
||||
| order_state_change | 订单状态变化 | orderNo, from, to, reason |
|
||||
| deposit_submit | 提交储入申请 | amountBucket, channel, result |
|
||||
| withdraw_submit | 提交取出申请 | amountBucket, fee, result |
|
||||
| support_open | 打开客服会话 | provider, entry, workStatus |
|
||||
| content_view | 查看规则或公告 | contentId, version, readDuration |
|
||||
| security_challenge | 触发安全验证 | scene, result, ruleId |
|
||||
|
||||
核心指标包括注册转化、首个任务完成率、领取成功率、订单超时率、储入成功率、取出成功率与处理时长、客服转人工率、异常账户率、账务差错率和投诉率。涉及金额与身份的数据只做区间化或匿名化统计。
|
||||
|
||||
# 12 验收标准
|
||||
|
||||
1. 未登录用户只能访问登录、注册和公开协议;受保护路由统一拦截。
|
||||
|
||||
2. 同一用户重复点击领取或提交订单,不产生重复业务单和重复资金流水。
|
||||
|
||||
3. 订单完成后,订单状态、钱包余额、三类流水和统计数据在一次事务或可验证最终一致性中完成。
|
||||
|
||||
4. 储入与取出所有状态均可在记录页查询,失败和驳回有明确原因。
|
||||
|
||||
5. 等级规则由后台发布后前台一致生效,表格、领取校验与提现限制不存在口径差异。
|
||||
|
||||
6. 手机号、银行卡、邀请码和交易密码不出现在客户端日志、服务端普通日志或分析平台。
|
||||
|
||||
7. 富文本、商品名、公告和客服消息中的脚本或危险 HTML 不能执行。
|
||||
|
||||
8. 接口 401、403、429、5xx、超时和断网场景均有清晰恢复路径。
|
||||
|
||||
9. 移动端 320 至 430 像素宽度可用,底部导航不遮挡内容,键盘弹出时表单按钮可达。
|
||||
|
||||
10. 关键资金和权限操作在后台可按用户、业务单号和时间完整审计。
|
||||
|
||||
# 13 交付优先级与里程碑
|
||||
|
||||
| **阶段** | **范围** | **退出条件** |
|
||||
| --- | --- | --- |
|
||||
| MVP 认证与账户 | 登录注册、资料、钱包、台账、内容、客服 | 权限、安全、审计基线通过 |
|
||||
| MVP 任务闭环 | 任务列表、领取、详情、提交、状态机、佣金 | 幂等和账务一致性测试通过 |
|
||||
| 资金闭环 | 储入、取出、收款绑定、支付通道、对账 | 支付与法务合规评审通过 |
|
||||
| 运营能力 | 等级、公告、横幅、商品、通知、后台 | 配置版本和发布审核可用 |
|
||||
| 增长与优化 | 邀请关系、数据看板、体验优化 | 合规批准且核心指标稳定 |
|
||||
|
||||
# 14 低保真原型
|
||||
|
||||
以下原型根据实测页面重新绘制,用于确认页面层级、组件位置和交互入口,不复制原站品牌素材、头像或敏感数据。视觉设计阶段应重新建立色彩、字体、图标和组件规范。
|
||||
|
||||

|
||||
|
||||
图 1 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 2 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 3 参考站点功能复刻原型
|
||||
|
||||

|
||||
|
||||
图 4 参考站点功能复刻原型
|
||||
|
||||
# 15 待确认事项
|
||||
|
||||
- 目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。
|
||||
- 任务来源、商品真实性、结算主体和佣金承担方。
|
||||
- 目标市场与适用法律、支付通道和实名要求。
|
||||
- 是否保留会员等级、推荐奖励、信用分和冻结机制。
|
||||
- 客服使用自建系统还是第三方服务,允许传输哪些用户数据。
|
||||
- 管理后台的组织结构、审批层级和财务对账责任人。
|
||||
- 品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。
|
||||
|
||||
在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。
|
||||
@@ -0,0 +1,26 @@
|
||||
module git.118im.com/im_team/im_workbench
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.12.1
|
||||
golang.org/x/crypto v0.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
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/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
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/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/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=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
|
||||
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,123 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type profileRepository struct {
|
||||
overview *store.MemberOverview
|
||||
security *store.AccountSecurity
|
||||
loginPasswordHash string
|
||||
tradePasswordHash string
|
||||
}
|
||||
|
||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||
func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return r.overview, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil
|
||||
}
|
||||
func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return r.security, nil
|
||||
}
|
||||
func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.loginPasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error {
|
||||
r.tradePasswordHash = newHash
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
}}
|
||||
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" {
|
||||
t.Fatalf("unexpected profile states: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordVerifiesAndHashes(t *testing.T) {
|
||||
currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "old-secret", NewPassword: "new-secret",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil {
|
||||
t.Fatal("new login password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) {
|
||||
currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
|
||||
OldPassword: "wrong-secret", NewPassword: "new-secret",
|
||||
})
|
||||
if !errors.Is(err, ErrCurrentPassword) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrCurrentPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTradePasswordRequiresSixDigits(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
for _, password := range []string{"", "12345", "1234567", "abcdef"} {
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: password,
|
||||
})
|
||||
if !errors.Is(err, ErrTradePassword) {
|
||||
t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword)
|
||||
}
|
||||
}
|
||||
if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil {
|
||||
t.Fatal("trade password was not bcrypt hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeTradePasswordRequiresOldPassword(t *testing.T) {
|
||||
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
|
||||
tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
|
||||
repository := &profileRepository{security: &store.AccountSecurity{
|
||||
PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash),
|
||||
}}
|
||||
service := &Service{repository: repository, ready: true}
|
||||
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
|
||||
LoginPassword: "login-secret", NewTradePassword: "654321",
|
||||
})
|
||||
if !errors.Is(err, ErrOldTradePasswordUnset) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("account service unavailable")
|
||||
ErrUsernameInvalid = errors.New("username invalid")
|
||||
ErrPhoneInvalid = errors.New("phone invalid")
|
||||
ErrPasswordInvalid = errors.New("password invalid")
|
||||
ErrReferralInvalid = errors.New("referral code invalid")
|
||||
ErrPrivacyNotAccepted = errors.New("privacy policy not accepted")
|
||||
ErrRateLimited = errors.New("login rate limited")
|
||||
ErrCurrentPassword = errors.New("current password invalid")
|
||||
ErrLoginPassword = errors.New("login password invalid")
|
||||
ErrTradePassword = errors.New("trade password invalid")
|
||||
ErrOldTradePassword = errors.New("old trade password invalid")
|
||||
ErrOldTradePasswordUnset = errors.New("old trade password is required")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
)
|
||||
|
||||
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}$`)
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||
AccountByMobile(context.Context, int64, string) (*store.Account, error)
|
||||
RecordLogin(context.Context, int64, int64) error
|
||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
||||
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
|
||||
UpdateLoginPassword(context.Context, int64, int64, string, string) error
|
||||
UpdateTradePassword(context.Context, int64, int64, string, string) error
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Username string
|
||||
Phone string
|
||||
Password string
|
||||
ReferralCode string
|
||||
PrivacyAccepted bool
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Phone string
|
||||
Password string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Account *store.Account
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type ProfileLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
}
|
||||
|
||||
type ProfileWallet struct {
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
TotalIncome int64 `json:"total_income"`
|
||||
TotalExpense int64 `json:"total_expense"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type AvatarResult struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
URL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type ChangePasswordInput struct {
|
||||
OldPassword string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type ChangeTradePasswordInput struct {
|
||||
LoginPassword string
|
||||
OldTradePassword string
|
||||
NewTradePassword string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
cfg config.AuthConfig
|
||||
ready bool
|
||||
}
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
redis: redisClient,
|
||||
cfg: cfg,
|
||||
ready: repository != nil && redisClient != nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.Phone = strings.TrimSpace(input.Phone)
|
||||
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
||||
if !usernamePattern.MatchString(input.Username) {
|
||||
return nil, ErrUsernameInvalid
|
||||
}
|
||||
if !phonePattern.MatchString(input.Phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
||||
return nil, ErrPasswordInvalid
|
||||
}
|
||||
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
if !input.PrivacyAccepted {
|
||||
return nil, ErrPrivacyNotAccepted
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberNo, err := newPublicID("M", 21)
|
||||
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 {
|
||||
if errors.Is(err, store.ErrReferralInvalid) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(ctx, account, input.DeviceID)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
phone := strings.TrimSpace(input.Phone)
|
||||
if !phonePattern.MatchString(phone) {
|
||||
return nil, ErrPhoneInvalid
|
||||
}
|
||||
if input.Password == "" || len([]byte(input.Password)) > 72 {
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
rateKey := s.loginRateKey(phone)
|
||||
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil {
|
||||
s.recordLoginFailure(ctx, rateKey)
|
||||
return nil, store.ErrInvalidCredentials
|
||||
}
|
||||
if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.issueSession(ctx, account, input.DeviceID)
|
||||
if err == nil {
|
||||
_ = s.redis.Del(ctx, rateKey).Err()
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
row, err := s.repository.MemberOverview(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Profile{
|
||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||
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),
|
||||
CreditScore: row.CreditScore,
|
||||
Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
||||
TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency,
|
||||
Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if attachmentID <= 0 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID)
|
||||
if errors.Is(err, store.ErrAttachmentInvalid) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil {
|
||||
return ErrPasswordInvalid
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error {
|
||||
if !s.ready {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if !tradePasswordPattern.MatchString(input.NewTradePassword) {
|
||||
return ErrTradePassword
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil {
|
||||
return ErrLoginPassword
|
||||
}
|
||||
if security.TradePasswordHash != "" {
|
||||
if input.OldTradePassword == "" {
|
||||
return ErrOldTradePasswordUnset
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil {
|
||||
return ErrTradePassword
|
||||
}
|
||||
} else if input.OldTradePassword != "" {
|
||||
return ErrOldTradePassword
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash))
|
||||
}
|
||||
|
||||
func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||
expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"member_id": account.MemberID,
|
||||
"tenant_id": account.TenantID,
|
||||
"device_id": strings.TrimSpace(deviceID),
|
||||
"state": "active",
|
||||
"issued_at": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"expires_at": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
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[:])
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
pipe := s.redis.TxPipeline()
|
||||
pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 15*time.Minute)
|
||||
_, _ = pipe.Exec(ctx)
|
||||
}
|
||||
|
||||
func sessionKey(pattern string, rawToken []byte) string {
|
||||
digest := sha256.Sum256(rawToken)
|
||||
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
|
||||
}
|
||||
|
||||
func newPublicID(prefix string, length int) (string, error) {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
raw := make([]byte, length-len(prefix))
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, len(raw))
|
||||
for i, value := range raw {
|
||||
result[i] = alphabet[int(value)%len(alphabet)]
|
||||
}
|
||||
return prefix + string(result), nil
|
||||
}
|
||||
|
||||
func memberStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func memberCategoryName(category int) string {
|
||||
if category == 2 {
|
||||
return "fake"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
|
||||
func walletStatusName(status int) string {
|
||||
if status == 1 {
|
||||
return "active"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
|
||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return nil, errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error {
|
||||
return errors.New("should not be called")
|
||||
}
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
input RegisterInput
|
||||
want error
|
||||
}{
|
||||
{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", 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},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := s.Register(context.Background(), test.input)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("Register() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
|
||||
s := &Service{}
|
||||
key := s.loginRateKey("13800138000")
|
||||
if strings.Contains(key, "13800138000") {
|
||||
t.Fatal("login rate key must not expose the phone number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRequiresMainlandPhone(t *testing.T) {
|
||||
s := &Service{repository: fakeRepository{}, ready: true}
|
||||
for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} {
|
||||
_, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"})
|
||||
if !errors.Is(err, ErrPhoneInvalid) {
|
||||
t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
store *store.Store
|
||||
redis *redis.Client
|
||||
api *httpapi.API
|
||||
frontendPath string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
st, err := store.Open(cfg.MySQL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
_ = redisClient.Close()
|
||||
_ = st.Close()
|
||||
return nil, err
|
||||
}
|
||||
authenticator := auth.New(cfg.Auth, redisClient)
|
||||
accountService := accounts.New(st, redisClient, cfg.Auth)
|
||||
portalService := portal.New(st)
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
if a.frontendDir() != "" {
|
||||
mux.Handle("/admin", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
|
||||
}
|
||||
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
a.api.Register(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *App) frontendDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return strings.TrimSpace(a.frontendPath)
|
||||
}
|
||||
|
||||
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")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
candidate := filepath.Join(dir, filepath.Clean(path))
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = path
|
||||
files.ServeHTTP(w, r2)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, index)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) Close() {
|
||||
if a.redis != nil {
|
||||
_ = a.redis.Close()
|
||||
}
|
||||
if a.store != nil {
|
||||
_ = a.store.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthRequired = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
MemberID int64 `json:"member_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
State string `json:"state"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
cfg config.AuthConfig
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
||||
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||
}
|
||||
|
||||
func (a *Authenticator) DefaultTenantID() int64 {
|
||||
return a.cfg.DefaultTenantID
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||
if err := a.ValidateAPIKey(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.AllowDevHeaderAuth {
|
||||
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
||||
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID,
|
||||
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
||||
}
|
||||
}
|
||||
if a.redis == nil {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
token, err := bearerToken(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := a.sessionFromRedis(req.Context(), token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.MemberID <= 0 || session.TenantID < 0 || session.State != "active" {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if err := a.validateMemberSession(req.Context(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Principal{TenantID: session.TenantID, MemberID: session.MemberID, DeviceID: session.DeviceID}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) Logout(req *http.Request) error {
|
||||
if _, err := a.Authenticate(req); err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := bearerToken(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.redis == nil {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
return a.redis.Del(req.Context(), key).Err()
|
||||
}
|
||||
|
||||
func (a *Authenticator) RevokeMemberSessions(ctx context.Context, tenantID, memberID int64) error {
|
||||
if a == nil || a.redis == nil {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
key := memberRevokedBeforeKey(tenantID, memberID)
|
||||
value := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
ttl := time.Duration(a.cfg.SessionTTLSeconds) * time.Second
|
||||
return a.redis.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
|
||||
func (a *Authenticator) ValidateAPIKey(req *http.Request) error {
|
||||
if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey {
|
||||
return nil
|
||||
}
|
||||
got := strings.TrimSpace(req.Header.Get("X-API-Key"))
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(req.URL.Query().Get("apikey"))
|
||||
}
|
||||
if got == "" {
|
||||
return ErrForbidden
|
||||
}
|
||||
if len(a.cfg.APIKeys) == 0 {
|
||||
return ErrForbidden
|
||||
}
|
||||
for _, key := range a.cfg.APIKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) {
|
||||
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
|
||||
if err != nil {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
data, err := a.redis.Get(ctx, key).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var session Session
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return nil, ErrAuthRequired
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) validateMemberSession(ctx context.Context, session *Session) error {
|
||||
revokedBefore, err := a.redis.Get(ctx, memberRevokedBeforeKey(session.TenantID, session.MemberID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
issuedAt, issuedErr := time.Parse(time.RFC3339Nano, session.IssuedAt)
|
||||
revokedAt, revokedErr := time.Parse(time.RFC3339Nano, revokedBefore)
|
||||
if issuedErr != nil || revokedErr != nil || !issuedAt.After(revokedAt) {
|
||||
return ErrAuthRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
||||
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
}
|
||||
|
||||
func sessionKeyFromToken(pattern, token string) (string, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return "", ErrAuthRequired
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1), nil
|
||||
}
|
||||
|
||||
func bearerToken(req *http.Request) (string, error) {
|
||||
authorization := strings.TrimSpace(req.Header.Get("Authorization"))
|
||||
token, ok := strings.CutPrefix(authorization, "Bearer ")
|
||||
if !ok || strings.TrimSpace(token) == "" {
|
||||
return "", ErrAuthRequired
|
||||
}
|
||||
return strings.TrimSpace(token), nil
|
||||
}
|
||||
|
||||
func parseInt64Header(req *http.Request, key string) int64 {
|
||||
value := strings.TrimSpace(req.Header.Get(key))
|
||||
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionKeyFromToken(t *testing.T) {
|
||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench: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) {
|
||||
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
FrontendDir string `json:"frontend_dir"`
|
||||
MySQL MySQLConfig `json:"mysql"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
ReadTimeout time.Duration `json:"-"`
|
||||
WriteTimeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
DSN string `json:"dsn"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
MaxIdleConns int `json:"max_idle_conns"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Addr string `json:"addr"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
DB int `json:"db"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
APIKeys []string `json:"api_keys"`
|
||||
SessionKeyPattern string `json:"session_key_pattern"`
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||
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 != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
applyEnv(cfg)
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Listen: ":6061",
|
||||
FrontendDir: "web/dist",
|
||||
MySQL: MySQLConfig{
|
||||
DSN: "root:root@tcp(127.0.0.1:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MaxOpenConns: 15,
|
||||
MaxIdleConns: 5,
|
||||
},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "workbench:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
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 != "" {
|
||||
cfg.Auth.APIKeys = splitCSV(keys)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if strings.TrimSpace(c.Listen) == "" {
|
||||
return errors.New("listen is required")
|
||||
}
|
||||
if strings.TrimSpace(c.MySQL.DSN) == "" {
|
||||
return errors.New("mysql dsn is required")
|
||||
}
|
||||
parsed, err := mysqlDriver.ParseDSN(strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mysql dsn is invalid: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(parsed.DBName), "olons_admin") {
|
||||
return errors.New("mysql database must be olons_admin")
|
||||
}
|
||||
if strings.TrimSpace(c.Redis.Addr) == "" {
|
||||
return errors.New("redis addr is required")
|
||||
}
|
||||
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
|
||||
return errors.New("session key pattern must include %s")
|
||||
}
|
||||
if c.Auth.SessionTTLSeconds <= 0 {
|
||||
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
||||
}
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setString(target *string, key string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
*target = value
|
||||
}
|
||||
}
|
||||
|
||||
func setInt(target *int, key string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
if parsed, err := strconv.Atoi(value); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setInt64(target *int64, key string) {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
*target = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setBool(target *bool, key string) {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
*target = true
|
||||
case "0", "false", "no", "off":
|
||||
*target = false
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if item := strings.TrimSpace(part); item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
if err := cfg.validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.DefaultTenantID != 0 {
|
||||
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRejectsOtherDatabase(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/other_database?parseTime=true"
|
||||
if err := cfg.validate(); err == nil {
|
||||
t.Fatal("expected non-olons_admin database to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package finance
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("finance service unavailable")
|
||||
ErrAccountTypeInvalid = errors.New("payment account type invalid")
|
||||
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
|
||||
ErrTradePassword = errors.New("trade password invalid")
|
||||
ErrTradePasswordUnset = errors.New("trade password is not set")
|
||||
ErrRateLimited = errors.New("trade password attempts rate limited")
|
||||
ErrCurrencyInvalid = errors.New("currency invalid")
|
||||
ErrAmountInvalid = errors.New("amount invalid")
|
||||
ErrIdempotencyKey = errors.New("idempotency key invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
ErrStatusInvalid = errors.New("withdrawal status invalid")
|
||||
ErrOrderIDInvalid = errors.New("withdrawal order id invalid")
|
||||
ErrRechargeStatusInvalid = errors.New("recharge status invalid")
|
||||
ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid")
|
||||
ErrRemarkInvalid = errors.New("remark invalid")
|
||||
ErrVoucherInvalid = errors.New("voucher attachment ids invalid")
|
||||
)
|
||||
@@ -0,0 +1,994 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
quoteTTL = 5 * time.Minute
|
||||
passwordMaxTry = 5
|
||||
)
|
||||
|
||||
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}$`)
|
||||
)
|
||||
|
||||
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)
|
||||
CreatePaymentAccount(context.Context, 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)
|
||||
CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error)
|
||||
WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error)
|
||||
WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error)
|
||||
RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error)
|
||||
CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error)
|
||||
RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error)
|
||||
RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
redis *redis.Client
|
||||
aead cipher.AEAD
|
||||
lookupKey []byte
|
||||
ready bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type QuoteInput struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
AccountMask string `json:"account_mask"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelCode string `json:"channel_code"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
RequestAmount int64 `json:"request_amount"`
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
ActualAmount int64 `json:"actual_amount"`
|
||||
Currency string `json:"currency"`
|
||||
MinimumAmount int64 `json:"minimum_amount"`
|
||||
MaximumAmount int64 `json:"maximum_amount"`
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
TodayCount int `json:"today_count"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
QuoteExpiresAt string `json:"quote_expires_at"`
|
||||
}
|
||||
|
||||
type CreateWithdrawalInput struct {
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
TradePassword string `json:"trade_password"`
|
||||
}
|
||||
|
||||
type Withdrawal struct {
|
||||
ID int64 `json:"id"`
|
||||
WithdrawalNo string `json:"withdrawal_no"`
|
||||
PaymentAccountID int64 `json:"payment_account_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
RequestAmount int64 `json:"request_amount"`
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
ActualAmount int64 `json:"actual_amount"`
|
||||
Currency string `json:"currency"`
|
||||
AccountMask string `json:"account_mask"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
ReasonText string `json:"reason_text"`
|
||||
ExternalNo string `json:"external_no,omitempty"`
|
||||
RiskResult json.RawMessage `json:"risk_result,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
ProcessedAt string `json:"processed_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
IdempotentReplay bool `json:"idempotent_replay,omitempty"`
|
||||
}
|
||||
|
||||
type WithdrawalPage struct {
|
||||
Items []Withdrawal `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type RechargeChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"channel_code"`
|
||||
Name string `json:"name"`
|
||||
ChannelType int `json:"channel_type"`
|
||||
MinimumAmount int64 `json:"minimum_amount"`
|
||||
MaximumAmount int64 `json:"maximum_amount"`
|
||||
FeeType int `json:"fee_type"`
|
||||
FeeFixedAmount int64 `json:"fee_fixed_amount"`
|
||||
FeeRate int `json:"fee_rate"`
|
||||
}
|
||||
|
||||
type CreateRechargeInput struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
VoucherAttachmentIDs []int64 `json:"voucher_attachment_ids"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type Recharge struct {
|
||||
ID int64 `json:"id"`
|
||||
RechargeNo string `json:"recharge_no"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
RequestAmount int64 `json:"request_amount"`
|
||||
PayableAmount int64 `json:"payable_amount"`
|
||||
PaidAmount int64 `json:"paid_amount"`
|
||||
Currency string `json:"currency"`
|
||||
VoucherAttachmentIDs json.RawMessage `json:"voucher_attachment_ids"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
ReasonText string `json:"reason_text"`
|
||||
Remark string `json:"remark"`
|
||||
PaymentInstructions string `json:"payment_instructions"`
|
||||
ExpireAt string `json:"expire_at,omitempty"`
|
||||
PaidAt string `json:"paid_at,omitempty"`
|
||||
ReviewedAt string `json:"reviewed_at,omitempty"`
|
||||
CreditedAt string `json:"credited_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RechargePage struct {
|
||||
Items []Recharge `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
HasNext bool `json:"has_next"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return service
|
||||
}
|
||||
service.aead = aead
|
||||
service.lookupKey = lookupKey
|
||||
service.ready = repository != nil && redisClient != nil
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PaymentAccount, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, paymentAccountFromStore(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
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
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := paymentAccountFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.quote(ctx, tenantID, memberID, input)
|
||||
}
|
||||
|
||||
func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64, idempotencyKey string, input CreateWithdrawalInput) (*Withdrawal, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if !idempotencyPattern.MatchString(idempotencyKey) {
|
||||
return nil, ErrIdempotencyKey
|
||||
}
|
||||
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quote, account, channel, err := s.quoteData(ctx, tenantID, memberID, QuoteInput{
|
||||
PaymentAccountID: input.PaymentAccountID, Amount: input.Amount, Currency: input.Currency})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holderName, err := s.decrypt(account.HolderCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
accountNo, err := s.decrypt(account.AccountNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
reservedPhone := ""
|
||||
if account.MobileCipher != "" {
|
||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshotCipher, err := s.encrypt(string(snapshot))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withdrawalNo := deterministicNumber("WD", tenantID, memberID, idempotencyKey)
|
||||
row, err := s.repository.CreateWithdrawal(ctx, store.WithdrawalCreation{
|
||||
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
|
||||
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,
|
||||
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
||||
BankName: account.BankName, HolderMask: account.HolderMask,
|
||||
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
|
||||
FrozenLedgerNo: deterministicNumber("LF", tenantID, memberID, idempotencyKey),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := withdrawalFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
statusCode, ok := withdrawalStatusCode(status)
|
||||
if !ok {
|
||||
return nil, ErrStatusInvalid
|
||||
}
|
||||
rows, err := s.repository.WithdrawalOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Withdrawal, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, withdrawalFromStore(row))
|
||||
}
|
||||
return &WithdrawalPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
row, err := s.repository.WithdrawalOrder(ctx, tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := withdrawalFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
rows, err := s.repository.RechargeChannels(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RechargeChannel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, RechargeChannel{ID: row.ID, Code: row.Code, Name: row.Name,
|
||||
ChannelType: row.ChannelType, MinimumAmount: greaterInt64(1, row.MinAmount),
|
||||
MaximumAmount: row.MaxAmount, FeeType: row.FeeType,
|
||||
FeeFixedAmount: row.FeeFixedAmount, FeeRate: row.FeeRate})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
|
||||
input.Remark = strings.TrimSpace(input.Remark)
|
||||
if input.Currency != "CNY" {
|
||||
return nil, ErrCurrencyInvalid
|
||||
}
|
||||
if input.ChannelID <= 0 {
|
||||
return nil, store.ErrRechargeChannel
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
return nil, store.ErrRechargeAmount
|
||||
}
|
||||
if utf8.RuneCountInString(input.Remark) > 500 {
|
||||
return nil, ErrRemarkInvalid
|
||||
}
|
||||
if len(input.VoucherAttachmentIDs) > 9 {
|
||||
return nil, ErrVoucherInvalid
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(input.VoucherAttachmentIDs))
|
||||
for _, id := range input.VoucherAttachmentIDs {
|
||||
if id <= 0 {
|
||||
return nil, ErrVoucherInvalid
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return nil, ErrVoucherInvalid
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
channels, err := s.repository.RechargeChannels(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var channel *store.RechargeChannel
|
||||
for i := range channels {
|
||||
if channels[i].ID == input.ChannelID {
|
||||
channel = &channels[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, store.ErrRechargeChannel
|
||||
}
|
||||
if input.Amount < greaterInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) {
|
||||
return nil, store.ErrRechargeAmount
|
||||
}
|
||||
orderNo, err := randomNumber("RC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repository.CreateRecharge(ctx, store.RechargeCreation{TenantID: tenantID,
|
||||
MemberID: memberID, RechargeNo: orderNo, ChannelID: input.ChannelID,
|
||||
RequestAmount: input.Amount, Currency: input.Currency,
|
||||
VoucherAttachmentIDs: input.VoucherAttachmentIDs, Remark: input.Remark})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := rechargeFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
statusCode, ok := rechargeStatusCode(status)
|
||||
if !ok {
|
||||
return nil, ErrRechargeStatusInvalid
|
||||
}
|
||||
rows, err := s.repository.RechargeOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Recharge, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, rechargeFromStore(row))
|
||||
}
|
||||
return &RechargePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total,
|
||||
HasNext: int64(page*pageSize) < rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if orderID <= 0 {
|
||||
return nil, ErrRechargeOrderIDInvalid
|
||||
}
|
||||
row, err := s.repository.RechargeOrder(ctx, tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := rechargeFromStore(*row)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
||||
return quote, err
|
||||
}
|
||||
|
||||
func (s *Service) quoteData(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, *store.PaymentAccount, *store.WithdrawalChannel, error) {
|
||||
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
|
||||
if input.PaymentAccountID <= 0 {
|
||||
return nil, nil, nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
if input.Currency != "CNY" {
|
||||
return nil, nil, nil, ErrCurrencyInvalid
|
||||
}
|
||||
if input.Amount <= 0 {
|
||||
return nil, nil, nil, ErrAmountInvalid
|
||||
}
|
||||
account, err := s.repository.PaymentAccountForWithdrawal(ctx, tenantID, memberID, input.PaymentAccountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if account.VerifyStatus != 2 {
|
||||
return nil, nil, nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
channel, err := s.repository.ActiveWithdrawalChannel(ctx, tenantID, channelTypes(account.AccountType))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
wallet, err := s.repository.WithdrawalWallet(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if wallet.AccountStatus != 1 {
|
||||
return nil, nil, nil, store.ErrAccountRestricted
|
||||
}
|
||||
minimum := channel.MinAmount
|
||||
if minimum < 1 {
|
||||
minimum = 1
|
||||
}
|
||||
maximum := channel.MaxAmount
|
||||
if maximum == 0 || maximum > wallet.AvailableAmount {
|
||||
maximum = wallet.AvailableAmount
|
||||
}
|
||||
if input.Amount < minimum || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) {
|
||||
return nil, nil, nil, store.ErrWithdrawalAmount
|
||||
}
|
||||
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
|
||||
return nil, nil, nil, store.ErrWithdrawalLimit
|
||||
}
|
||||
if input.Amount > wallet.AvailableAmount {
|
||||
return nil, nil, nil, store.ErrInsufficientBalance
|
||||
}
|
||||
fee, err := withdrawalFee(input.Amount, *channel)
|
||||
if err != nil || fee < 0 || fee >= input.Amount {
|
||||
return nil, nil, nil, store.ErrWithdrawalAmount
|
||||
}
|
||||
return &Quote{PaymentAccountID: account.ID, AccountMask: account.AccountNoMask,
|
||||
ChannelID: channel.ID, ChannelCode: channel.Code, ChannelName: channel.Name,
|
||||
RequestAmount: input.Amount, FeeAmount: fee, ActualAmount: input.Amount - fee,
|
||||
Currency: input.Currency, MinimumAmount: minimum, MaximumAmount: maximum,
|
||||
AvailableAmount: wallet.AvailableAmount, TodayCount: wallet.TodayCount, DailyLimit: wallet.DailyLimit,
|
||||
QuoteExpiresAt: s.now().UTC().Add(quoteTTL).Format(time.RFC3339)}, account, channel, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) error {
|
||||
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") {
|
||||
return store.ErrWithdrawalDisabled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID int64, password string) (*store.AccountSecurity, error) {
|
||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
key := fmt.Sprintf("workbench: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
|
||||
}
|
||||
if attempts >= passwordMaxTry {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if security.TradePasswordHash == "" {
|
||||
return nil, ErrTradePasswordUnset
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(password)) != nil {
|
||||
pipe := s.redis.TxPipeline()
|
||||
pipe.Incr(ctx, key)
|
||||
pipe.Expire(ctx, key, 15*time.Minute)
|
||||
if _, rateErr := pipe.Exec(ctx); rateErr != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
if err := s.redis.Del(ctx, key).Err(); err != nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
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))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal {
|
||||
risk := row.RiskResult
|
||||
if len(risk) == 0 {
|
||||
risk = json.RawMessage(`{}`)
|
||||
}
|
||||
return Withdrawal{ID: row.ID, WithdrawalNo: row.WithdrawalNo, PaymentAccountID: row.PaymentAccountID,
|
||||
ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount,
|
||||
FeeAmount: row.FeeAmount, ActualAmount: row.ActualAmount, Currency: row.Currency,
|
||||
AccountMask: row.AccountMask, BankName: row.BankName, HolderMask: row.HolderMask,
|
||||
Status: withdrawalStatusName(row.Status), StatusText: withdrawalStatusText(row.Status),
|
||||
ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, ExternalNo: row.ExternalNo,
|
||||
RiskResult: risk, ReviewedAt: row.ReviewedAt, ProcessedAt: row.ProcessedAt,
|
||||
CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
IdempotentReplay: row.Replayed}
|
||||
}
|
||||
|
||||
func rechargeFromStore(row store.RechargeOrder) Recharge {
|
||||
vouchers := row.VoucherAttachmentIDs
|
||||
if len(vouchers) == 0 {
|
||||
vouchers = json.RawMessage(`[]`)
|
||||
}
|
||||
result := Recharge{ID: row.ID, RechargeNo: row.RechargeNo, ChannelID: row.ChannelID,
|
||||
ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, PayableAmount: row.RequestAmount,
|
||||
PaidAmount: row.PaidAmount, Currency: row.Currency, VoucherAttachmentIDs: vouchers,
|
||||
Status: rechargeStatusName(row.Status), StatusText: rechargeStatusText(row.Status),
|
||||
ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, Remark: row.Remark,
|
||||
PaymentInstructions: "沙箱订单,请按页面提示完成后续操作,不要向个人账户转账。",
|
||||
PaidAt: row.PaidAt, ReviewedAt: row.ReviewedAt, CreditedAt: row.CreditedAt,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
if row.Status == 1 && row.CreatedAt != "" {
|
||||
if created, err := time.Parse(time.RFC3339, row.CreatedAt); err == nil {
|
||||
result.ExpireAt = created.Add(30 * time.Minute).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rechargeStatusCode(value string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return 0, true
|
||||
case "pending_payment":
|
||||
return 1, true
|
||||
case "reviewing", "processing":
|
||||
return 2, true
|
||||
case "succeeded":
|
||||
return 3, true
|
||||
case "failed":
|
||||
return 4, true
|
||||
case "expired":
|
||||
return 5, true
|
||||
case "cancelled":
|
||||
return 6, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func rechargeStatusName(value int) string {
|
||||
names := map[int]string{1: "pending_payment", 2: "reviewing", 3: "succeeded", 4: "failed", 5: "expired", 6: "cancelled"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func rechargeStatusText(value int) string {
|
||||
names := map[int]string{1: "待支付", 2: "审核中", 3: "已成功", 4: "失败", 5: "已过期", 6: "已取消"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) {
|
||||
if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 {
|
||||
return 0, store.ErrWithdrawalChannel
|
||||
}
|
||||
rate := int64(channel.FeeRate)
|
||||
rateFee := (amount/10000)*rate + ((amount%10000)*rate+9999)/10000
|
||||
switch channel.FeeType {
|
||||
case 1:
|
||||
return channel.FeeFixedAmount, nil
|
||||
case 2:
|
||||
return rateFee, nil
|
||||
case 3:
|
||||
return channel.FeeFixedAmount + rateFee, nil
|
||||
default:
|
||||
return 0, store.ErrWithdrawalChannel
|
||||
}
|
||||
}
|
||||
|
||||
func channelTypes(accountType int) []int {
|
||||
specific := map[int]int{1: 3, 2: 4, 3: 3, 4: 2, 5: 1}[accountType]
|
||||
if specific == 0 || specific == 4 {
|
||||
return []int{4}
|
||||
}
|
||||
return []int{specific, 4}
|
||||
}
|
||||
|
||||
func accountTypeName(value int) string {
|
||||
names := map[int]string{1: "bank_card", 2: "digital_wallet", 3: "unionpay", 4: "alipay", 5: "wechat"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func verifyStatusName(value int) string {
|
||||
names := map[int]string{1: "pending", 2: "verified", 3: "rejected"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func withdrawalStatusCode(value string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return 0, true
|
||||
case "pending_review":
|
||||
return 1, true
|
||||
case "reviewing":
|
||||
return 2, true
|
||||
case "processing":
|
||||
return 3, true
|
||||
case "succeeded":
|
||||
return 4, true
|
||||
case "failed":
|
||||
return 5, true
|
||||
case "rejected":
|
||||
return 6, true
|
||||
case "cancelled":
|
||||
return 7, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawalStatusName(value int) string {
|
||||
names := map[int]string{1: "pending_review", 2: "reviewing", 3: "processing", 4: "succeeded", 5: "failed", 6: "rejected", 7: "cancelled"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func withdrawalStatusText(value int) string {
|
||||
names := map[int]string{1: "待审核", 2: "审核中", 3: "处理中", 4: "已成功", 5: "失败", 6: "已驳回", 7: "已取消"}
|
||||
if name := names[value]; name != "" {
|
||||
return name
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func (s *Service) encrypt(value string) (string, error) {
|
||||
nonce := make([]byte, s.aead.NonceSize())
|
||||
if _, err := rand.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) decrypt(value string) (string, error) {
|
||||
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(payload) <= s.aead.NonceSize() {
|
||||
return "", errors.New("encrypted value is invalid")
|
||||
}
|
||||
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func (s *Service) lookupHash(value string) string {
|
||||
mac := hmac.New(sha256.New, s.lookupKey)
|
||||
_, _ = mac.Write([]byte(value))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func deterministicNumber(prefix string, tenantID, memberID int64, key string) string {
|
||||
digest := sha256.Sum256([]byte(strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) + ":" + key + ":" + prefix))
|
||||
return prefix + strings.ToUpper(hex.EncodeToString(digest[:16]))
|
||||
}
|
||||
|
||||
func randomNumber(prefix string) (string, error) {
|
||||
random := make([]byte, 12)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(random)), nil
|
||||
}
|
||||
|
||||
func greaterInt64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func decodeKey(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(value)
|
||||
}
|
||||
|
||||
func maskPhone(value string) string {
|
||||
if len(value) != 11 {
|
||||
return ""
|
||||
}
|
||||
return value[:3] + "****" + value[7:]
|
||||
}
|
||||
|
||||
func maskName(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(runes) == 1 {
|
||||
return string(runes[0]) + "*"
|
||||
}
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
|
||||
}
|
||||
|
||||
func maskAccount(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 4 {
|
||||
return strings.Repeat("*", len(runes))
|
||||
}
|
||||
if len(runes) <= 6 {
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1])
|
||||
}
|
||||
prefix := 4
|
||||
if len(runes) < 12 {
|
||||
prefix = 2
|
||||
}
|
||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func validHumanName(value string) bool {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) || r == '<' || r == '>' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
account *store.PaymentAccount
|
||||
channel *store.WithdrawalChannel
|
||||
wallet *store.WithdrawalWallet
|
||||
orders []store.WithdrawalOrder
|
||||
rechargeChannels []store.RechargeChannel
|
||||
rechargeOrders []store.RechargeOrder
|
||||
createdRecharge store.RechargeCreation
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return f.config, nil
|
||||
}
|
||||
func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRepository) CreatePaymentAccount(context.Context, 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
|
||||
}
|
||||
func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) {
|
||||
return f.channel, nil
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) {
|
||||
return f.wallet, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) {
|
||||
return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil
|
||||
}
|
||||
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (f *fakeRepository) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) {
|
||||
return f.rechargeChannels, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeCreation) (*store.RechargeOrder, error) {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
||||
account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2},
|
||||
channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3,
|
||||
MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25},
|
||||
wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}}
|
||||
service := &Service{repository: repository, ready: true, now: func() time.Time {
|
||||
return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
|
||||
result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 ||
|
||||
result.QuoteExpiresAt != "2026-10-09T02:05:00Z" {
|
||||
t.Fatalf("unexpected quote: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFailsClosed(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
|
||||
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
|
||||
if !errors.Is(err, store.ErrWithdrawalDisabled) {
|
||||
t.Fatalf("expected disabled error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
|
||||
if got := maskAccount("abcde"); got != "a***e" {
|
||||
t.Fatalf("unexpected mask %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithdrawalStatusValidation(t *testing.T) {
|
||||
service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now}
|
||||
_, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad")
|
||||
if !errors.Is(err, ErrStatusInvalid) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
|
||||
ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000,
|
||||
}}}
|
||||
service := &Service{repository: repository, ready: true, now: time.Now}
|
||||
result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{
|
||||
ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
|
||||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) {
|
||||
repository := &fakeRepository{rechargeOrders: []store.RechargeOrder{{
|
||||
ID: 3, RechargeNo: "RC3", RequestAmount: 20000, Currency: "CNY", Status: 3,
|
||||
}}}
|
||||
service := &Service{repository: repository, ready: true, now: time.Now}
|
||||
result, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "succeeded")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].Status != "succeeded" || result.HasNext {
|
||||
t.Fatalf("unexpected recharge page: %#v", result)
|
||||
}
|
||||
if _, err = service.RechargeOrders(context.Background(), 0, 9, 1, 20, "invalid"); !errors.Is(err, ErrRechargeStatusInvalid) {
|
||||
t.Fatalf("expected recharge status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
|
||||
first := deterministicNumber("WD", 0, 1, "request-key")
|
||||
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
||||
t.Fatal("deterministic number is not stable or member scoped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptionRoundTrip(t *testing.T) {
|
||||
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
||||
DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=",
|
||||
})
|
||||
ciphertext, err := service.encrypt("6222000000001234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := service.decrypt(ciphertext)
|
||||
if err != nil || plain != "6222000000001234" {
|
||||
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
ReferralCode string `json:"referral_code,omitempty"`
|
||||
PrivacyAccepted bool `json:"privacy_accepted"`
|
||||
}
|
||||
|
||||
type accountLoginRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (a *API) accountRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountRegisterRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Register(r.Context(), accountsvc.RegisterInput{
|
||||
Username: body.Username, Phone: body.Phone, Password: body.Password,
|
||||
ReferralCode: body.ReferralCode, PrivacyAccepted: body.PrivacyAccepted,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if !a.validatePublicClient(w, r) {
|
||||
return
|
||||
}
|
||||
var body accountLoginRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.accounts.Login(r.Context(), accountsvc.LoginInput{
|
||||
Phone: body.Phone, Password: body.Password,
|
||||
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
|
||||
})
|
||||
if err != nil {
|
||||
writeAccountError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, accountResultPayload(result))
|
||||
}
|
||||
|
||||
func (a *API) accountLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r)
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
if err := a.auth.Logout(r); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrAuthRequired):
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
case errors.Is(err, auth.ErrForbidden):
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) validatePublicClient(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.accounts == nil || a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
if err := a.auth.ValidateAPIKey(r); err != nil {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "valid API key required", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func accountResultPayload(result *accountsvc.Result) map[string]any {
|
||||
return map[string]any{
|
||||
"member_id": result.Account.MemberID,
|
||||
"member_no": result.Account.MemberNo,
|
||||
"username": result.Account.Username,
|
||||
"phone": result.Account.Mobile,
|
||||
"invite_code": result.Account.InviteCode,
|
||||
"token": result.Token,
|
||||
"token_type": "Bearer",
|
||||
"expires_at": result.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
func writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrUsernameInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "USERNAME_INVALID_FORMAT", "validation failed", map[string]any{"field": "username"})
|
||||
case errors.Is(err, accountsvc.ErrPhoneInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PHONE_INVALID_FORMAT", "validation failed", map[string]any{"field": "phone"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PASSWORD_INVALID_FORMAT", "validation failed", map[string]any{"field": "password"})
|
||||
case errors.Is(err, accountsvc.ErrReferralInvalid), errors.Is(err, store.ErrReferralInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "REFERRAL_CODE_INVALID", "validation failed", map[string]any{"field": "referral_code"})
|
||||
case errors.Is(err, accountsvc.ErrPrivacyNotAccepted):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRIVACY_POLICY_NOT_ACCEPTED", "validation failed", map[string]any{"field": "privacy_accepted"})
|
||||
case errors.Is(err, store.ErrAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_ALREADY_REGISTERED", "account already registered", nil)
|
||||
case errors.Is(err, store.ErrInvalidCredentials):
|
||||
writeError(w, r, http.StatusUnauthorized, 30043, "INVALID_CREDENTIALS", "invalid phone or password", nil)
|
||||
case errors.Is(err, accountsvc.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many login attempts", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
auth *auth.Authenticator
|
||||
accounts *accounts.Service
|
||||
portal *portal.Service
|
||||
tasks *tasks.Service
|
||||
mall *mall.Service
|
||||
finance *finance.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 (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/auth/register", a.accountRegister)
|
||||
mux.HandleFunc("/v1/auth/login", a.accountLogin)
|
||||
mux.HandleFunc("/v1/auth/logout", a.accountLogout)
|
||||
mux.HandleFunc("/v1/me", a.memberProfile)
|
||||
mux.HandleFunc("/v1/me/avatar", a.memberAvatar)
|
||||
mux.HandleFunc("/v1/me/password", a.memberPassword)
|
||||
mux.HandleFunc("/v1/me/trade-password", a.memberTradePassword)
|
||||
mux.HandleFunc("/v1/home", a.home)
|
||||
mux.HandleFunc("/v1/banners", a.banners)
|
||||
mux.HandleFunc("/v1/contents/", a.content)
|
||||
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
||||
mux.HandleFunc("/v1/task-orders/claim", a.taskClaim)
|
||||
mux.HandleFunc("/v1/task-orders", a.taskOrders)
|
||||
mux.HandleFunc("/v1/task-orders/", a.taskOrderResource)
|
||||
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
||||
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
|
||||
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
||||
mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder)
|
||||
mux.HandleFunc("/v1/withdrawal-orders/quote", a.withdrawalQuote)
|
||||
mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders)
|
||||
mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder)
|
||||
}
|
||||
|
||||
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
result, err := a.finance.PaymentAccounts(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.PaymentAccountInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
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) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body finance.QuoteInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.Quote(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) rechargeChannels(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
|
||||
}
|
||||
result, err := a.finance.RechargeChannels(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) rechargeOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.finance.RechargeOrders(r.Context(), principal.TenantID, principal.MemberID,
|
||||
page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.CreateRechargeInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreateRecharge(r.Context(), principal.TenantID, principal.MemberID, body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) rechargeOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/recharge-orders/"), "/")
|
||||
if orderIDText == "" || strings.Contains(orderIDText, "/") {
|
||||
writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.RechargeOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrders(r.Context(), principal.TenantID, principal.MemberID,
|
||||
page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
var body finance.CreateWithdrawalInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.CreateWithdrawal(r.Context(), principal.TenantID, principal.MemberID,
|
||||
r.Header.Get("Idempotency-Key"), body)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) withdrawalOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/withdrawal-orders/"), "/")
|
||||
if orderIDText == "" || strings.Contains(orderIDText, "/") {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateFinanceMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.finance.WithdrawalOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateFinanceMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.finance == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, finance.ErrAccountTypeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ACCOUNT_TYPE_INVALID", "validation failed", map[string]any{"field": "account_type"})
|
||||
case errors.Is(err, finance.ErrAccountFieldsInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAYMENT_ACCOUNT_FIELDS_INVALID", "validation failed", nil)
|
||||
case errors.Is(err, finance.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "trade_password"})
|
||||
case errors.Is(err, finance.ErrTradePasswordUnset):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TRADE_PASSWORD_NOT_SET", "trade password is not set", nil)
|
||||
case errors.Is(err, finance.ErrRateLimited):
|
||||
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many trade password attempts", map[string]any{"retry_after_seconds": 900})
|
||||
case errors.Is(err, finance.ErrCurrencyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"})
|
||||
case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil)
|
||||
case errors.Is(err, store.ErrRechargeAmount):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "RECHARGE_AMOUNT_INVALID", "recharge amount is outside the allowed range", nil)
|
||||
case errors.Is(err, finance.ErrRemarkInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_REMARK_INVALID", "validation failed", map[string]any{"field": "remark"})
|
||||
case errors.Is(err, finance.ErrVoucherInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"})
|
||||
case errors.Is(err, finance.ErrIdempotencyKey):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"})
|
||||
case errors.Is(err, finance.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, finance.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, finance.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, finance.ErrRechargeStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, finance.ErrRechargeOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, finance.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, store.ErrPaymentAccountExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PAYMENT_ACCOUNT_EXISTS", "payment account already exists", nil)
|
||||
case errors.Is(err, store.ErrPaymentAccountInvalid):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "PAYMENT_ACCOUNT_INVALID", "payment account is unavailable or unverified", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalChannel):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil)
|
||||
case errors.Is(err, store.ErrRechargeChannel):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "RECHARGE_CHANNEL_UNAVAILABLE", "recharge channel is unavailable", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalLimit):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrWithdrawalNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil)
|
||||
case errors.Is(err, store.ErrRechargeNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "RECHARGE_ORDER_NOT_FOUND", "recharge order not found", nil)
|
||||
case errors.Is(err, store.ErrRechargeVoucher):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"})
|
||||
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
|
||||
case errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "QR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "qr_attachment_id"})
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, finance.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
categoryID, ok := optionalNonNegativeInt64(r.URL.Query().Get("category_id"))
|
||||
if !ok {
|
||||
writeMallError(w, r, mall.ErrCategoryIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Products(r.Context(), principal.TenantID, categoryID, page, pageSize)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) manualProduct(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if a.mall == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
productIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/manual-products/"), "/")
|
||||
if productIDText == "" || strings.Contains(productIDText, "/") {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
productID, err := strconv.ParseInt(productIDText, 10, 64)
|
||||
if err != nil || productID <= 0 {
|
||||
writeMallError(w, r, mall.ErrProductIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.mall.Product(r.Context(), principal.TenantID, productID)
|
||||
if err != nil {
|
||||
writeMallError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func optionalNonNegativeInt64(value string) (int64, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
return parsed, err == nil && parsed >= 0
|
||||
}
|
||||
|
||||
func writeMallError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, mall.ErrCategoryIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CATEGORY_ID_INVALID", "validation failed", map[string]any{"field": "category_id"})
|
||||
case errors.Is(err, mall.ErrProductIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PRODUCT_ID_INVALID", "validation failed", map[string]any{"field": "product_id"})
|
||||
case errors.Is(err, mall.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, mall.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "PRODUCT_NOT_FOUND", "manual product not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
}
|
||||
|
||||
func (a *API) home(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
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
result, err := a.portal.Home(r.Context(), principal.TenantID, principal.MemberID, profile)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) banners(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
|
||||
}
|
||||
position := strings.TrimSpace(r.URL.Query().Get("position"))
|
||||
if position == "" {
|
||||
position = "home_top"
|
||||
}
|
||||
items, err := a.portal.Banners(r.Context(), principal.TenantID, position)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
if a.auth == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/v1/contents/")
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if strings.Contains(key, "/") {
|
||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||
return
|
||||
}
|
||||
tenantID := a.auth.DefaultTenantID()
|
||||
if _, public := publicContentKeys[key]; !public {
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID = principal.TenantID
|
||||
}
|
||||
result, err := a.portal.Content(r.Context(), tenantID, key)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) memberLevels(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
|
||||
}
|
||||
items, err := a.portal.MemberLevels(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) leaderboard(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
|
||||
}
|
||||
items, err := a.portal.Leaderboard(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) customerServices(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
|
||||
}
|
||||
items, err := a.portal.CustomerServices(r.Context(), principal.TenantID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, items)
|
||||
}
|
||||
|
||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
func writePortalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, portal.ErrPositionInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"})
|
||||
case errors.Is(err, portal.ErrContentKeyInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"})
|
||||
case errors.Is(err, store.ErrResourceNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package httpapi
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type avatarRequest struct {
|
||||
AttachmentID int64 `json:"attachment_id"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type tradePasswordRequest struct {
|
||||
LoginPassword string `json:"login_password"`
|
||||
OldTradePassword string `json:"old_trade_password,omitempty"`
|
||||
NewTradePassword string `json:"new_trade_password"`
|
||||
}
|
||||
|
||||
func (a *API) memberProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, profile)
|
||||
}
|
||||
|
||||
func (a *API) memberAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
methodNotAllowed(w, r, http.MethodPatch)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body avatarRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
avatar, err := a.accounts.ChangeAvatar(r.Context(), principal.TenantID, principal.MemberID, body.AttachmentID)
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, avatar)
|
||||
}
|
||||
|
||||
func (a *API) memberPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body passwordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangePasswordInput{
|
||||
OldPassword: body.OldPassword, NewPassword: body.NewPassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.auth.RevokeMemberSessions(r.Context(), principal.TenantID, principal.MemberID); err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) memberTradePassword(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodPut)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body tradePasswordRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
err := a.accounts.ChangeTradePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangeTradePasswordInput{
|
||||
LoginPassword: body.LoginPassword, OldTradePassword: body.OldTradePassword,
|
||||
NewTradePassword: body.NewTradePassword,
|
||||
})
|
||||
if err != nil {
|
||||
writeProfileError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, nil)
|
||||
}
|
||||
|
||||
func (a *API) authenticateMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.auth == nil || a.accounts == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, err := a.auth.Authenticate(r)
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
if errors.Is(err, auth.ErrForbidden) {
|
||||
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
|
||||
} else if errors.Is(err, auth.ErrAuthRequired) {
|
||||
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
|
||||
} else {
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func writeProfileError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountsvc.ErrAttachmentInvalid), errors.Is(err, store.ErrAttachmentInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "AVATAR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "attachment_id"})
|
||||
case errors.Is(err, accountsvc.ErrCurrentPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "CURRENT_PASSWORD_INVALID", "current password is invalid", map[string]any{"field": "old_password"})
|
||||
case errors.Is(err, accountsvc.ErrLoginPassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "LOGIN_PASSWORD_INVALID", "login password is invalid", map[string]any{"field": "login_password"})
|
||||
case errors.Is(err, accountsvc.ErrPasswordInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "NEW_PASSWORD_INVALID", "validation failed", map[string]any{"field": "new_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePasswordUnset):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_REQUIRED", "old trade password is required", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrOldTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_INVALID", "old trade password is invalid", map[string]any{"field": "old_trade_password"})
|
||||
case errors.Is(err, accountsvc.ErrTradePassword):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "new_trade_password"})
|
||||
case errors.Is(err, store.ErrAccountNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ACCOUNT_NOT_FOUND", "account not found", nil)
|
||||
case errors.Is(err, store.ErrConcurrentUpdate):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
|
||||
case errors.Is(err, accountsvc.ErrUnavailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
reasonMalformedRequest = "MALFORMED_REQUEST"
|
||||
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
|
||||
reasonForbidden = "FORBIDDEN"
|
||||
reasonInternal = "INTERNAL"
|
||||
)
|
||||
|
||||
type apiError struct {
|
||||
Code int `json:"code"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
ServerTime string `json:"server_time"`
|
||||
}
|
||||
|
||||
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
|
||||
writeJSON(w, r, http.StatusOK, map[string]any{
|
||||
"code": 0, "data": data, "request_id": requestID(r),
|
||||
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
})
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
|
||||
writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details,
|
||||
RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if requestID := requestID(r); requestID != "" {
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func requestID(r *http.Request) string {
|
||||
return r.Header.Get("X-Request-ID")
|
||||
}
|
||||
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func methodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) {
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{http.MethodPost}
|
||||
}
|
||||
w.Header().Set("Allow", strings.Join(allowed, ", "))
|
||||
writeError(w, r, http.StatusMethodNotAllowed, 30005, reasonMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"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"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
HallID int64 `json:"hall_id"`
|
||||
}
|
||||
|
||||
func (a *API) taskHalls(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Halls(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskEligibility(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hallID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("hall_id")), 10, 64)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, tasks.ErrHallIDInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Eligibility(r.Context(), principal.TenantID, principal.MemberID, hallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Stats(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskClaim(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body taskClaimRequest
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Claim(r.Context(), principal.TenantID, principal.MemberID, body.HallID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, ok := optionalPositiveInt(w, r, "page")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageInvalid)
|
||||
return
|
||||
}
|
||||
pageSize, ok := optionalPositiveInt(w, r, "page_size")
|
||||
if !ok {
|
||||
writeTaskError(w, r, tasks.ErrPageSizeInvalid)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Orders(r.Context(), principal.TenantID, principal.MemberID, page, pageSize, r.URL.Query().Get("status"))
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) taskOrderResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/task-orders/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 1 || len(parts) > 2 || parts[0] == "" {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
orderID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateTaskMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Order(r.Context(), principal.TenantID, principal.MemberID, orderID)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
return
|
||||
}
|
||||
if parts[1] != "submissions" {
|
||||
writeTaskError(w, r, store.ErrOrderNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
var body tasks.SubmitInput
|
||||
if !decodeBody(w, r, &body) {
|
||||
return
|
||||
}
|
||||
result, err := a.tasks.Submit(r.Context(), principal.TenantID, principal.MemberID, orderID, body)
|
||||
if err != nil {
|
||||
writeTaskError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) authenticateTaskMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
|
||||
if a.tasks == nil {
|
||||
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
|
||||
return nil, false
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func optionalPositiveInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
|
||||
value := strings.TrimSpace(r.URL.Query().Get(name))
|
||||
if value == "" {
|
||||
return 0, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
return parsed, err == nil && parsed > 0
|
||||
}
|
||||
|
||||
func writeTaskError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, tasks.ErrHallIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "HALL_ID_INVALID", "validation failed", map[string]any{"field": "hall_id"})
|
||||
case errors.Is(err, tasks.ErrOrderIDInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||
case errors.Is(err, tasks.ErrPageInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
|
||||
case errors.Is(err, tasks.ErrPageSizeInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||
case errors.Is(err, tasks.ErrStatusInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
||||
case errors.Is(err, tasks.ErrEvidenceInvalid), errors.Is(err, store.ErrEvidenceInvalid):
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "EVIDENCE_INVALID", "validation failed", map[string]any{"field": "evidence"})
|
||||
case errors.Is(err, tasks.ErrTaskDisabled), errors.Is(err, store.ErrTaskDisabled):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TASK_DISABLED", "task feature is disabled", nil)
|
||||
case errors.Is(err, tasks.ErrOutsideBusinessHour), errors.Is(err, store.ErrOutsideBusinessHours):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "OUTSIDE_BUSINESS_HOURS", "outside business hours", nil)
|
||||
case errors.Is(err, store.ErrInsufficientBalance):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
|
||||
case errors.Is(err, store.ErrDailyLimitReached):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "DAILY_LIMIT_REACHED", "daily claim limit reached", nil)
|
||||
case errors.Is(err, store.ErrLevelNotEligible), errors.Is(err, store.ErrTaskHallNotFound):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "LEVEL_NOT_ELIGIBLE", "member level is not eligible", nil)
|
||||
case errors.Is(err, store.ErrAccountNotFound), errors.Is(err, store.ErrAccountRestricted):
|
||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||
case errors.Is(err, store.ErrPendingOrderExists):
|
||||
writeError(w, r, http.StatusConflict, 30040, "PENDING_ORDER_EXISTS", "unfinished order exists", nil)
|
||||
case errors.Is(err, store.ErrNoTaskAvailable):
|
||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "NO_TASK_AVAILABLE", "no task is currently available", nil)
|
||||
case errors.Is(err, store.ErrOrderNotFound):
|
||||
writeError(w, r, http.StatusNotFound, 30044, "ORDER_NOT_FOUND", "task order not found", nil)
|
||||
case errors.Is(err, store.ErrOrderState):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_STATE_INVALID", "task order state does not allow this operation", nil)
|
||||
case errors.Is(err, store.ErrOrderExpired):
|
||||
writeError(w, r, http.StatusConflict, 30040, "ORDER_EXPIRED", "task order has expired", nil)
|
||||
default:
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package mall
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrCategoryIDInvalid = errors.New("category id invalid")
|
||||
ErrProductIDInvalid = errors.New("product id invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
package mall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
maxSummaryRunes = 120
|
||||
)
|
||||
|
||||
var (
|
||||
scriptBlockPattern = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
|
||||
styleBlockPattern = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
|
||||
htmlTagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error)
|
||||
ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
ProductNo string `json:"product_no"`
|
||||
Category Category `json:"category"`
|
||||
Name string `json:"name"`
|
||||
ShopName string `json:"shop_name"`
|
||||
PriceAmount int64 `json:"price_amount"`
|
||||
Currency string `json:"currency"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Summary string `json:"summary"`
|
||||
ClaimedCount int64 `json:"claimed_count"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
IsLimited bool `json:"is_limited"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
AvailableActions []string `json:"available_actions"`
|
||||
PaymentType string `json:"payment_type,omitempty"`
|
||||
DetailText string `json:"detail_text,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ProductPage struct {
|
||||
Items []Product `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Products(ctx context.Context, tenantID, categoryID int64, page, pageSize int) (*ProductPage, error) {
|
||||
if categoryID < 0 {
|
||||
return nil, ErrCategoryIDInvalid
|
||||
}
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
rows, err := s.repository.ManualProducts(ctx, tenantID, categoryID, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Product, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, productFromStore(row, false))
|
||||
}
|
||||
return &ProductPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Product(ctx context.Context, tenantID, productID int64) (*Product, error) {
|
||||
if productID <= 0 {
|
||||
return nil, ErrProductIDInvalid
|
||||
}
|
||||
row, err := s.repository.ManualProduct(ctx, tenantID, productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := productFromStore(*row, true)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func productFromStore(row store.ManualProduct, detail bool) Product {
|
||||
detailText := plainText(row.DetailHTML)
|
||||
result := Product{ID: row.ID, ProductNo: row.ProductNo,
|
||||
Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName},
|
||||
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
|
||||
CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes),
|
||||
ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended, IsLimited: row.EndAt != "",
|
||||
StartAt: row.StartAt, EndAt: row.EndAt,
|
||||
AvailableActions: []string{"contact_support"}}
|
||||
if detail {
|
||||
result.PaymentType = row.PaymentType
|
||||
result.DetailText = detailText
|
||||
result.CreatedAt = row.CreatedAt
|
||||
result.UpdatedAt = row.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func plainText(raw string) string {
|
||||
raw = scriptBlockPattern.ReplaceAllString(raw, " ")
|
||||
raw = styleBlockPattern.ReplaceAllString(raw, " ")
|
||||
withoutTags := htmlTagPattern.ReplaceAllString(raw, " ")
|
||||
return strings.Join(strings.Fields(html.UnescapeString(withoutTags)), " ")
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
if utf8.RuneCountInString(value) <= limit {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
func safeHTTPSURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package mall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
items []store.ManualProduct
|
||||
detail *store.ManualProduct
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) {
|
||||
return &store.ManualProductPage{Items: f.items, Total: int64(len(f.items))}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) {
|
||||
return f.detail, nil
|
||||
}
|
||||
|
||||
func TestProductsMapsSafeCard(t *testing.T) {
|
||||
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
|
||||
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
|
||||
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
|
||||
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true,
|
||||
StartAt: "2026-10-01T00:00:00Z", EndAt: "2026-10-31T00:00:00Z"}}})
|
||||
|
||||
result, err := service.Products(context.Background(), 0, 0, 1, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" ||
|
||||
!result.Items[0].IsLimited || result.Items[0].EndAt == "" || len(result.Items[0].AvailableActions) != 1 ||
|
||||
result.Items[0].AvailableActions[0] != "contact_support" {
|
||||
t.Fatalf("unexpected product: %#v", result.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductRejectsUnsafeCoverAndReturnsPlainText(t *testing.T) {
|
||||
service := New(&fakeRepository{detail: &store.ManualProduct{ID: 4, Name: "商品",
|
||||
CoverURL: "javascript:alert(1)", DetailHTML: "<p>安全说明</p>"}})
|
||||
|
||||
result, err := service.Product(context.Background(), 0, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CoverURL != "" || result.DetailText != "安全说明" {
|
||||
t.Fatalf("unexpected detail: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductsValidatesPagination(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
if _, err := service.Products(context.Background(), 0, 0, -1, 20); !errors.Is(err, ErrPageInvalid) {
|
||||
t.Fatalf("expected page error, got %v", err)
|
||||
}
|
||||
if _, err := service.Products(context.Background(), 0, -1, 1, 20); !errors.Is(err, ErrCategoryIDInvalid) {
|
||||
t.Fatalf("expected category error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package portal
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPositionInvalid = errors.New("banner position invalid")
|
||||
ErrContentKeyInvalid = errors.New("content key invalid")
|
||||
)
|
||||
@@ -0,0 +1,339 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
positionPattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
|
||||
contentKeyPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
ActiveBanners(context.Context, int64, string) ([]store.Banner, error)
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||
ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Features struct {
|
||||
TaskEnabled bool `json:"task_enabled"`
|
||||
RechargeEnabled bool `json:"recharge_enabled"`
|
||||
WithdrawalEnabled bool `json:"withdrawal_enabled"`
|
||||
ManualMallEnabled bool `json:"manual_mall_enabled"`
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
AltText string `json:"alt_text"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetValue string `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
}
|
||||
|
||||
type Home struct {
|
||||
Profile HomeProfile `json:"profile"`
|
||||
Wallet accounts.ProfileWallet `json:"wallet"`
|
||||
Features Features `json:"features"`
|
||||
Banners []Banner `json:"banners"`
|
||||
Notices []Notice `json:"notices"`
|
||||
TaskSummary store.TaskSummary `json:"task_summary"`
|
||||
}
|
||||
|
||||
type HomeProfile struct {
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
LevelName string `json:"level_name"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ContentKey string `json:"content_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
BodyText string `json:"body_text"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
Version int `json:"version"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
type MemberLevel struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
UpgradeAmount int64 `json:"upgrade_amount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
DailyOrderLimit int `json:"daily_order_limit"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
DailyWithdrawLimit int `json:"daily_withdraw_limit"`
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type LeaderboardEntry struct {
|
||||
Rank int `json:"rank"`
|
||||
Username string `json:"username"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
CompletedCount int64 `json:"completed_count"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CustomerService struct {
|
||||
ID int64 `json:"id"`
|
||||
ServiceCode string `json:"service_code"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
WorkTime string `json:"work_time"`
|
||||
OnlineStatus string `json:"online_status"`
|
||||
ServiceURL string `json:"service_url"`
|
||||
BackupContact string `json:"backup_contact"`
|
||||
PrivacyNotice string `json:"privacy_notice"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository}
|
||||
}
|
||||
|
||||
func (s *Service) Home(ctx context.Context, tenantID, memberID int64, profile *accounts.Profile) (*Home, error) {
|
||||
config, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
banners, err := s.Banners(ctx, tenantID, "home_top")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
noticeRows, err := s.repository.ActiveMemberNotices(ctx, tenantID, memberID, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.repository.MemberTaskSummary(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notices := make([]Notice, 0, len(noticeRows))
|
||||
for _, row := range noticeRows {
|
||||
notices = append(notices, Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt})
|
||||
}
|
||||
return &Home{
|
||||
Profile: HomeProfile{Username: profile.Username, AvatarURL: profile.AvatarURL, LevelName: profile.Level.Name},
|
||||
Wallet: profile.Wallet, Features: parseFeatures(config), Banners: banners, Notices: notices,
|
||||
TaskSummary: *summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Banners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
|
||||
position = strings.ToLower(strings.TrimSpace(position))
|
||||
if !positionPattern.MatchString(position) {
|
||||
return nil, ErrPositionInvalid
|
||||
}
|
||||
rows, err := s.repository.ActiveBanners(ctx, tenantID, position)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Banner, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
targetType, targetValue := bannerTarget(row.TargetType, row.TargetValue)
|
||||
items = append(items, Banner{ID: row.ID, ImageURL: row.ImageURL, AltText: row.AltText,
|
||||
TargetType: targetType, TargetValue: targetValue})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
row, err := s.repository.PublishedContent(ctx, tenantID, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assetIDs := make([]int64, 0)
|
||||
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,
|
||||
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
|
||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
|
||||
rows, err := s.repository.ActiveMemberLevels(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]MemberLevel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rules := row.RuleJSON
|
||||
if len(rules) == 0 {
|
||||
rules = json.RawMessage(`{}`)
|
||||
}
|
||||
items = append(items, MemberLevel{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
||||
UpgradeAmount: row.UpgradeAmount, GiftAmount: row.GiftAmount, DailyOrderLimit: row.DailyOrderLimit,
|
||||
CommissionRate: row.CommissionRate, DailyWithdrawLimit: row.DailyWithdrawLimit,
|
||||
Rules: rules, Version: row.Version})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]LeaderboardEntry, error) {
|
||||
rows, err := s.repository.TaskLeaderboard(ctx, tenantID, 50)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]LeaderboardEntry, 0, len(rows))
|
||||
for index, row := range rows {
|
||||
items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username),
|
||||
AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) {
|
||||
rows, err := s.repository.ActiveCustomerServices(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]CustomerService, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, CustomerService{
|
||||
ID: row.ID, ServiceCode: row.ServiceCode, Name: row.Name,
|
||||
AvatarURL: safeExternalURL(row.AvatarURL), WorkTime: row.WorkTime,
|
||||
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
|
||||
BackupContact: row.BackupContact,
|
||||
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func maskedUsername(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
switch len(runes) {
|
||||
case 0:
|
||||
return "会员"
|
||||
case 1:
|
||||
return string(runes[0]) + "*"
|
||||
case 2:
|
||||
return string(runes[0]) + "*"
|
||||
default:
|
||||
return string(runes[0]) + "***" + string(runes[len(runes)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func parseFeatures(raw json.RawMessage) Features {
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
return Features{}
|
||||
}
|
||||
return Features{
|
||||
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
|
||||
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
|
||||
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
|
||||
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
|
||||
}
|
||||
}
|
||||
|
||||
func findBool(config map[string]any, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := config[key]; ok {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
if nested, ok := config["features"].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, found := nested[key]; found {
|
||||
return boolValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func bannerTarget(kind int, value string) (string, string) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch kind {
|
||||
case 2:
|
||||
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
|
||||
return "internal_route", value
|
||||
}
|
||||
case 3:
|
||||
parsed, err := url.Parse(value)
|
||||
if err == nil && parsed.Scheme == "https" && parsed.Host != "" {
|
||||
return "external_url", value
|
||||
}
|
||||
}
|
||||
return "none", ""
|
||||
}
|
||||
|
||||
func safeExternalURL(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func customerServiceStatus(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
return "online"
|
||||
case 3:
|
||||
return "busy"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
func noticeSourceName(source int) string {
|
||||
if source == 2 {
|
||||
return "operational_simulation"
|
||||
}
|
||||
return "real"
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
banners []store.Banner
|
||||
content *store.Content
|
||||
levels []store.MemberLevel
|
||||
notices []store.MemberNotice
|
||||
summary *store.TaskSummary
|
||||
leaders []store.LeaderboardMember
|
||||
services []store.CustomerService
|
||||
}
|
||||
|
||||
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return r.config, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveBanners(context.Context, int64, string) ([]store.Banner, error) {
|
||||
return r.banners, nil
|
||||
}
|
||||
func (r fakeRepository) PublishedContent(context.Context, int64, string) (*store.Content, error) {
|
||||
if r.content == nil {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
return r.content, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) {
|
||||
return r.levels, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
|
||||
return r.summary, nil
|
||||
}
|
||||
func (r fakeRepository) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) {
|
||||
return r.leaders, nil
|
||||
}
|
||||
func (r fakeRepository) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) {
|
||||
return r.services, nil
|
||||
}
|
||||
|
||||
func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
||||
features := parseFeatures(json.RawMessage(`{
|
||||
"features":{"task_enabled":true,"rechargeEnabled":1},
|
||||
"WITHDRAWAL":"on","SHOP_STATUS":"false"
|
||||
}`))
|
||||
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || features.ManualMallEnabled {
|
||||
t.Fatalf("unexpected features: %+v", features)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersSanitizeTargets(t *testing.T) {
|
||||
service := New(fakeRepository{banners: []store.Banner{
|
||||
{ID: 1, TargetType: 2, TargetValue: "/receive"},
|
||||
{ID: 2, TargetType: 2, TargetValue: "//evil.example"},
|
||||
{ID: 3, TargetType: 3, TargetValue: "https://example.com/path"},
|
||||
{ID: 4, TargetType: 3, TargetValue: "javascript:alert(1)"},
|
||||
}})
|
||||
items, err := service.Banners(context.Background(), 0, "home_top")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTypes := []string{"internal_route", "none", "external_url", "none"}
|
||||
for i, want := range wantTypes {
|
||||
if items[i].TargetType != want {
|
||||
t.Errorf("item %d target type = %q, want %q", i, items[i].TargetType, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannersRejectInvalidPosition(t *testing.T) {
|
||||
service := New(fakeRepository{})
|
||||
if _, err := service.Banners(context.Background(), 0, "../home"); !errors.Is(err, ErrPositionInvalid) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrPositionInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentMapsAssets(t *testing.T) {
|
||||
service := New(fakeRepository{content: &store.Content{
|
||||
ContentKey: "privacy_policy", Title: "Policy", AssetIDs: sql.NullString{String: `[1,2]`, Valid: true},
|
||||
}})
|
||||
content, err := service.Content(context.Background(), 0, "privacy_policy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(content.AssetIDs) != 2 || content.AssetIDs[0] != 1 || content.AssetIDs[1] != 2 {
|
||||
t.Fatalf("unexpected asset IDs: %v", content.AssetIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberLevelsAlwaysReturnJSONObjectRules(t *testing.T) {
|
||||
service := New(fakeRepository{levels: []store.MemberLevel{{ID: 1, Code: "VIP1", Name: "Normal"}}})
|
||||
levels, err := service.MemberLevels(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(levels[0].Rules) != `{}` {
|
||||
t.Fatalf("rules = %s, want {}", levels[0].Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
|
||||
service := New(fakeRepository{
|
||||
config: json.RawMessage(`{"features":{"task_enabled":true}}`),
|
||||
summary: &store.TaskSummary{TodayCount: 2, TodayCommissionAmount: 150},
|
||||
})
|
||||
home, err := service.Home(context.Background(), 0, 7, &accounts.Profile{
|
||||
Username: "alice", Level: accounts.ProfileLevel{Name: "VIP1"},
|
||||
Wallet: accounts.ProfileWallet{Currency: "CNY"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(home)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, field := range []string{`"today_count":2`, `"today_commission_amount":150`, `"task_enabled":true`} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Errorf("home JSON %s does not contain %s", text, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) {
|
||||
service := New(fakeRepository{leaders: []store.LeaderboardMember{
|
||||
{MemberID: 7, Username: "风轻云淡", CompletedCount: 12},
|
||||
{MemberID: 8, Username: "李明", CompletedCount: 9},
|
||||
}})
|
||||
items, err := service.Leaderboard(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" ||
|
||||
items[1].Username != "李*" || items[1].Status != "领取成功" {
|
||||
t.Fatalf("unexpected leaderboard: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) {
|
||||
service := New(fakeRepository{services: []store.CustomerService{
|
||||
{ID: 1, Name: "在线客服", AvatarURL: "https://example.com/avatar.png", ServiceURL: "https://example.com/chat", OnlineStatus: 1},
|
||||
{ID: 2, Name: "离线客服", AvatarURL: "javascript:alert(1)", ServiceURL: "http://example.com/chat", OnlineStatus: 2},
|
||||
}})
|
||||
items, err := service.CustomerServices(context.Background(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].OnlineStatus != "online" || items[0].ServiceURL == "" ||
|
||||
items[1].OnlineStatus != "offline" || items[1].AvatarURL != "" || items[1].ServiceURL != "" {
|
||||
t.Fatalf("unexpected customer services: %+v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
memberStatusActive = 1
|
||||
memberCategoryReal = 1
|
||||
)
|
||||
|
||||
type AccountRegistration struct {
|
||||
TenantID int64
|
||||
MemberNo string
|
||||
Username string
|
||||
Mobile string
|
||||
PasswordHash string
|
||||
ReferralCode string
|
||||
InviteCode string
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
TenantID int64 `db:"tenant_id"`
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
Mobile string `db:"mobile"`
|
||||
PasswordHash string `db:"password_hash"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
}
|
||||
|
||||
func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration) (*Account, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
parentID, teamLevel, tree, err := referralParent(ctx, tx, input.TenantID, input.ReferralCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
levelID, err := defaultMemberLevel(ctx, tx, input.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member
|
||||
(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)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrAccountExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
memberID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
|
||||
(tenant_id,member_id,created_by,updated_by) VALUES(?,?,0,0)`, input.TenantID, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_wallet_account
|
||||
(tenant_id,member_id,currency,available_amount,frozen_amount,total_income,total_expense,account_status,version)
|
||||
VALUES(?,?,'CNY',0,0,0,0,1,0)`, input.TenantID, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo,
|
||||
Username: input.Username, Mobile: input.Mobile,
|
||||
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
return accountByMobile(ctx, s.db, tenantID, mobile)
|
||||
}
|
||||
|
||||
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET last_login_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND id=? AND deleted_at IS NULL`, time.Now().UTC(), time.Now().UTC(), tenantID, memberID)
|
||||
return err
|
||||
}
|
||||
|
||||
type queryer interface {
|
||||
GetContext(context.Context, any, string, ...any) error
|
||||
}
|
||||
|
||||
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
|
||||
var row Account
|
||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
||||
password_hash, invite_code, member_status, risk_status AS member_category
|
||||
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.MemberStatus != memberStatusActive {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
var parent struct {
|
||||
ID int64 `db:"id"`
|
||||
Level int `db:"level"`
|
||||
Tree string `db:"tree"`
|
||||
}
|
||||
err := tx.GetContext(ctx, &parent, `SELECT id,level,tree FROM hg_app_member
|
||||
WHERE tenant_id=? AND invite_code=? AND member_status=1
|
||||
AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, 0, "", ErrReferralInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, "", err
|
||||
}
|
||||
tree := strings.Trim(strings.TrimSpace(parent.Tree)+","+formatInt64(parent.ID), ",")
|
||||
if len(tree) > 512 {
|
||||
return 0, 0, "", ErrReferralInvalid
|
||||
}
|
||||
return parent.ID, parent.Level + 1, tree, nil
|
||||
}
|
||||
|
||||
func defaultMemberLevel(ctx context.Context, tx *sqlx.Tx, tenantID int64) (int64, error) {
|
||||
var id int64
|
||||
err := tx.GetContext(ctx, &id, `SELECT id FROM hg_app_member_level
|
||||
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC LIMIT 1`, tenantID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
func formatInt64(value int64) string {
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrAccountExists = errors.New("account already exists")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrAccountRestricted = errors.New("account restricted")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
ErrConcurrentUpdate = errors.New("concurrent update")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrReferralInvalid = errors.New("referral code invalid")
|
||||
ErrResourceNotFound = errors.New("resource not found")
|
||||
ErrTaskHallNotFound = errors.New("task hall not found")
|
||||
ErrTaskDisabled = errors.New("task feature disabled")
|
||||
ErrOutsideBusinessHours = errors.New("outside business hours")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrDailyLimitReached = errors.New("daily limit reached")
|
||||
ErrLevelNotEligible = errors.New("member level not eligible")
|
||||
ErrPendingOrderExists = errors.New("pending order exists")
|
||||
ErrPaymentAccountExists = errors.New("payment account already exists")
|
||||
ErrPaymentAccountInvalid = errors.New("payment account invalid")
|
||||
ErrWithdrawalDisabled = errors.New("withdrawal disabled")
|
||||
ErrWithdrawalChannel = errors.New("withdrawal channel unavailable")
|
||||
ErrWithdrawalAmount = errors.New("withdrawal amount invalid")
|
||||
ErrWithdrawalLimit = errors.New("withdrawal daily limit reached")
|
||||
ErrWithdrawalNotFound = errors.New("withdrawal order not found")
|
||||
ErrRechargeChannel = errors.New("recharge channel unavailable")
|
||||
ErrRechargeAmount = errors.New("recharge amount invalid")
|
||||
ErrRechargeNotFound = errors.New("recharge order not found")
|
||||
ErrRechargeVoucher = errors.New("recharge voucher invalid")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key reused with different request")
|
||||
ErrNoTaskAvailable = errors.New("no task available")
|
||||
ErrOrderNotFound = errors.New("task order not found")
|
||||
ErrOrderState = errors.New("task order state invalid")
|
||||
ErrOrderExpired = errors.New("task order expired")
|
||||
ErrEvidenceInvalid = errors.New("task evidence invalid")
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ManualProduct struct {
|
||||
ID int64 `db:"id"`
|
||||
ProductNo string `db:"product_no"`
|
||||
CategoryID int64 `db:"category_id"`
|
||||
CategoryCode string `db:"category_code"`
|
||||
CategoryName string `db:"category_name"`
|
||||
PaymentType string `db:"payment_type"`
|
||||
Name string `db:"name"`
|
||||
ShopName string `db:"shop_name"`
|
||||
PriceAmount int64 `db:"price_amount"`
|
||||
Currency string `db:"currency"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
DetailHTML string `db:"detail_html"`
|
||||
IsRecommended bool `db:"is_recommended"`
|
||||
StartAt string `db:"start_at"`
|
||||
EndAt string `db:"end_at"`
|
||||
ClaimedCount int64 `db:"claimed_count"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ManualProductPage struct {
|
||||
Items []ManualProduct
|
||||
Total int64
|
||||
}
|
||||
|
||||
func (s *Store) ManualProducts(ctx context.Context, tenantID, categoryID int64, offset, limit int) (*ManualProductPage, error) {
|
||||
where := ` WHERE p.tenant_id=? AND p.product_type=2 AND p.publish_status=3
|
||||
AND p.deleted_at IS NULL AND c.status=1 AND c.deleted_at IS NULL
|
||||
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
|
||||
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3))`
|
||||
args := []any{tenantID}
|
||||
if categoryID > 0 {
|
||||
where += ` AND p.category_id=?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_product p
|
||||
JOIN hg_product_category c ON c.tenant_id=p.tenant_id AND c.id=p.category_id`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
items := make([]ManualProduct, 0)
|
||||
err := s.db.SelectContext(ctx, &items, manualProductSelectSQL()+where+
|
||||
` ORDER BY p.is_recommended ASC,p.sort ASC,p.id ASC LIMIT ? OFFSET ?`, args...)
|
||||
return &ManualProductPage{Items: items, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) ManualProduct(ctx context.Context, tenantID, productID int64) (*ManualProduct, error) {
|
||||
var row ManualProduct
|
||||
err := s.db.GetContext(ctx, &row, manualProductSelectSQL()+` WHERE p.tenant_id=? AND p.id=?
|
||||
AND p.product_type=2 AND p.publish_status=3 AND p.deleted_at IS NULL
|
||||
AND c.status=1 AND c.deleted_at IS NULL
|
||||
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
|
||||
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3)) LIMIT 1`, tenantID, productID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func manualProductSelectSQL() string {
|
||||
return `SELECT p.id,p.product_no,p.category_id,c.category_code,c.name AS category_name,
|
||||
p.payment_type,p.name,p.shop_name,p.price_amount,p.currency,p.cover_url,
|
||||
COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended,
|
||||
COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at,
|
||||
COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at,
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id
|
||||
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
|
||||
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_product p JOIN hg_product_category c
|
||||
ON c.tenant_id=p.tenant_id AND c.id=p.category_id`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func Open(cfg config.MySQLConfig) (*Store, error) {
|
||||
db, err := sqlx.Open("mysql", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const publishStatusPublished = 3
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `db:"id"`
|
||||
ImageURL string `db:"image_url"`
|
||||
AltText string `db:"alt_text"`
|
||||
TargetType int `db:"target_type"`
|
||||
TargetValue string `db:"target_value"`
|
||||
Sort int `db:"sort"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ContentKey string `db:"content_key"`
|
||||
ContentType string `db:"content_type"`
|
||||
Title string `db:"title"`
|
||||
Summary string `db:"summary"`
|
||||
BodyHTML string `db:"body_html"`
|
||||
BodyText string `db:"body_text"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
Version int `db:"version"`
|
||||
PublishedAt string `db:"published_at"`
|
||||
AssetIDs sql.NullString `db:"asset_ids"`
|
||||
}
|
||||
|
||||
type MemberLevel struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"level_code"`
|
||||
Name string `db:"name"`
|
||||
IconURL string `db:"icon_url"`
|
||||
UpgradeAmount int64 `db:"upgrade_amount"`
|
||||
GiftAmount int64 `db:"gift_amount"`
|
||||
DailyOrderLimit int `db:"daily_order_limit"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
DailyWithdrawLimit int `db:"daily_withdraw_limit"`
|
||||
RuleJSON json.RawMessage `db:"rule_json"`
|
||||
Version int `db:"version"`
|
||||
Sort int `db:"sort"`
|
||||
}
|
||||
|
||||
type MemberNotice struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Content string `db:"content"`
|
||||
SourceType int `db:"source_type"`
|
||||
StartAt string `db:"start_at"`
|
||||
EndAt string `db:"end_at"`
|
||||
}
|
||||
|
||||
type TaskSummary struct {
|
||||
TodayCount int `db:"today_count" json:"today_count"`
|
||||
TodayCommissionAmount int64 `db:"today_commission_amount" json:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"`
|
||||
}
|
||||
|
||||
type LeaderboardMember struct {
|
||||
MemberID int64 `db:"member_id"`
|
||||
Username string `db:"username"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
CompletedCount int64 `db:"completed_count"`
|
||||
}
|
||||
|
||||
type CustomerService struct {
|
||||
ID int64 `db:"id"`
|
||||
ServiceCode string `db:"service_code"`
|
||||
Name string `db:"name"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
WorkTime string `db:"work_time"`
|
||||
OnlineStatus int `db:"online_status"`
|
||||
ServiceURL string `db:"service_url"`
|
||||
BackupContact string `db:"backup_contact"`
|
||||
}
|
||||
|
||||
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var raw []byte
|
||||
err := s.db.GetContext(ctx, &raw, `SELECT config_json FROM hg_site_config_version
|
||||
WHERE tenant_id=? AND publish_status=? AND (effective_at IS NULL OR effective_at<=UTC_TIMESTAMP(3))
|
||||
ORDER BY COALESCE(effective_at,published_at,created_at) DESC,id DESC LIMIT 1`, tenantID, publishStatusPublished)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
return json.RawMessage(raw), err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveBanners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]Banner, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,image_url,alt_text,target_type,COALESCE(target_value,'') AS target_value,sort
|
||||
FROM hg_banner WHERE tenant_id=? AND position=? AND publish_status=?
|
||||
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
|
||||
AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID, position, publishStatusPublished)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) PublishedContent(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row Content
|
||||
err := s.db.GetContext(ctx, &row, `SELECT c.content_key,c.content_type,v.title,COALESCE(v.summary,'') AS summary,
|
||||
v.body_html,COALESCE(v.body_text,'') AS body_text,COALESCE(v.cover_url,'') AS cover_url,
|
||||
v.version_no AS version,COALESCE(DATE_FORMAT(c.published_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS published_at,v.asset_ids
|
||||
FROM hg_content c JOIN hg_content_version v
|
||||
ON v.tenant_id=c.tenant_id AND v.id=c.published_version_id
|
||||
WHERE c.tenant_id=? AND c.content_key=? AND c.publish_status=? AND v.publish_status=?
|
||||
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
|
||||
AND c.deleted_at IS NULL LIMIT 1`, tenantID, key, publishStatusPublished, publishStatusPublished)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrResourceNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveMemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberLevel, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT l.id,l.level_code,l.name,l.icon_url,l.sort,
|
||||
v.upgrade_amount,v.gift_amount,v.daily_order_limit,v.commission_rate,v.daily_withdraw_limit,
|
||||
COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json,v.version_no AS version
|
||||
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
|
||||
WHERE l.tenant_id=? AND l.status=1 AND v.publish_status=?
|
||||
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
|
||||
AND l.deleted_at IS NULL ORDER BY l.sort ASC,l.id ASC`, tenantID, publishStatusPublished)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int64, limit int) ([]MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberNotice, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at
|
||||
FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row TaskSummary
|
||||
err := s.db.GetContext(ctx, &row, `SELECT
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=? AND o.member_id=?
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
|
||||
(SELECT COALESCE(SUM(t.commission_amount),0) FROM hg_task_submission t WHERE t.tenant_id=? AND t.member_id=?
|
||||
AND t.submission_status=4 AND t.settled_at>=UTC_DATE() AND t.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(y.commission_amount),0) FROM hg_task_submission y WHERE y.tenant_id=? AND y.member_id=?
|
||||
AND y.submission_status=4 AND y.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND y.settled_at<UTC_DATE()) AS yesterday_commission_amount`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int) ([]LeaderboardMember, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]LeaderboardMember, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username,
|
||||
COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
|
||||
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
|
||||
JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id
|
||||
AND o.order_type=2 AND o.order_status=4
|
||||
WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL
|
||||
GROUP BY m.id,m.username,a.file_url
|
||||
ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
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
|
||||
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
|
||||
return rows, err
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type MemberOverview struct {
|
||||
TenantID int64 `db:"tenant_id"`
|
||||
MemberID int64 `db:"member_id"`
|
||||
MemberNo string `db:"member_no"`
|
||||
Username string `db:"username"`
|
||||
Mobile string `db:"mobile"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
CreditScore int `db:"credit_score"`
|
||||
AvatarAttachmentID int64 `db:"avatar_attachment_id"`
|
||||
AvatarURL string `db:"avatar_url"`
|
||||
LevelID int64 `db:"level_id"`
|
||||
LevelCode string `db:"level_code"`
|
||||
LevelName string `db:"level_name"`
|
||||
LevelIconURL string `db:"level_icon_url"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TotalIncome int64 `db:"total_income"`
|
||||
TotalExpense int64 `db:"total_expense"`
|
||||
Currency string `db:"currency"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
WalletVersion int `db:"wallet_version"`
|
||||
}
|
||||
|
||||
type AccountSecurity struct {
|
||||
PasswordHash string `db:"password_hash"`
|
||||
TradePasswordHash string `db:"trade_password_hash"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
}
|
||||
|
||||
type Avatar struct {
|
||||
AttachmentID int64 `db:"attachment_id"`
|
||||
URL string `db:"url"`
|
||||
}
|
||||
|
||||
func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*MemberOverview, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
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,
|
||||
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,
|
||||
COALESCE(lv.commission_rate,0) AS commission_rate,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
|
||||
COALESCE(w.total_income,0) AS total_income,COALESCE(w.total_expense,0) AS total_expense,
|
||||
COALESCE(w.currency,'CNY') AS currency,COALESCE(w.account_status,1) AS wallet_status,
|
||||
COALESCE(w.version,0) AS wallet_version
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
|
||||
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*Avatar, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var activeMemberID int64
|
||||
err = tx.GetContext(ctx, &activeMemberID, `SELECT id FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
|
||||
(tenant_id,member_id,avatar_attachment_id,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0) ON DUPLICATE KEY UPDATE avatar_attachment_id=VALUES(avatar_attachment_id),updated_at=CURRENT_TIMESTAMP(3)`,
|
||||
tenantID, memberID, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &avatar, nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountSecurity(ctx context.Context, tenantID, memberID int64) (*AccountSecurity, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row AccountSecurity
|
||||
err := s.db.GetContext(ctx, &row, `SELECT password_hash,trade_password_hash,member_status
|
||||
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) UpdateLoginPassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTradePassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET trade_password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL`,
|
||||
newHash, tenantID, memberID, oldHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
orderStatusMatching = 1
|
||||
orderStatusPending = 2
|
||||
orderStatusProcessing = 3
|
||||
orderStatusCompleted = 4
|
||||
orderStatusExpired = 5
|
||||
orderStatusFrozen = 6
|
||||
orderStatusCancelled = 7
|
||||
orderStatusFailed = 8
|
||||
)
|
||||
|
||||
type TaskHall struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"hall_code"`
|
||||
Name string `db:"name"`
|
||||
IconURL string `db:"icon_url"`
|
||||
Summary string `db:"summary"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
RateUnit string `db:"rate_unit"`
|
||||
ClientRoute string `db:"client_route"`
|
||||
}
|
||||
|
||||
type TaskEligibility struct {
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
HallEligible bool `db:"hall_eligible"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
PendingOrderID int64 `db:"pending_order_id"`
|
||||
}
|
||||
|
||||
type ClaimTaskInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
HallID int64
|
||||
MinimumAmount int64
|
||||
DeadlineAt time.Time
|
||||
OrderNo string
|
||||
}
|
||||
|
||||
type TaskOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
ProductID int64 `db:"product_id"`
|
||||
ProductName string `db:"product_name"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
Quantity int `db:"quantity"`
|
||||
UnitPriceAmount int64 `db:"unit_price_amount"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
Currency string `db:"currency"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||
Message string `db:"message"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
FreezeStatus int `db:"freeze_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
MatchedAt string `db:"matched_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
TaskTitle string `db:"task_title"`
|
||||
TaskDescription string `db:"task_description"`
|
||||
TaskDetailHTML string `db:"task_detail_html"`
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
SubmissionStatus int `db:"submission_status"`
|
||||
EvidenceText string `db:"evidence_text"`
|
||||
EvidenceIDs json.RawMessage `db:"evidence_attachment_ids"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
AllowResubmit int `db:"allow_resubmit"`
|
||||
SubmittedAt string `db:"submitted_at"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
SettledAt string `db:"settled_at"`
|
||||
}
|
||||
|
||||
type TaskOrderPage struct {
|
||||
Items []TaskOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
type TaskStats struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
TodayCommissionAmount int64 `db:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount"`
|
||||
TodayClaimCount int `db:"today_claim_count"`
|
||||
TodayCompletedCount int `db:"today_completed_count"`
|
||||
}
|
||||
|
||||
type TaskSubmissionInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
}
|
||||
|
||||
type TaskSubmissionResult struct {
|
||||
SubmissionNo string `db:"submission_no"`
|
||||
OrderStatus int
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]TaskHall, error) {
|
||||
rows := make([]TaskHall, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, `SELECT h.id,h.hall_code,h.name,h.icon_url,h.summary,
|
||||
h.commission_rate,h.rate_unit,h.client_route FROM hg_task_hall h
|
||||
JOIN hg_app_member m ON m.tenant_id=h.tenant_id AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL
|
||||
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=? AND h.publish_status=3 AND h.deleted_at IS NULL ORDER BY h.sort,h.id`, memberID, tenantID)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.account_status,0) AS wallet_status,
|
||||
COALESCE(lv.daily_order_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=m.tenant_id AND o.member_id=m.id
|
||||
AND o.order_type=2 AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
|
||||
COALESCE((SELECT o2.id FROM hg_trade_order o2 WHERE o2.tenant_id=m.tenant_id AND o2.member_id=m.id
|
||||
AND o2.order_type=2 AND o2.order_status IN (1,2,3,6) ORDER BY o2.id DESC LIMIT 1),0) AS pending_order_id
|
||||
FROM hg_app_member m
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.deleted_at IS NULL LIMIT 1`, hallID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var member struct {
|
||||
LevelID int64 `db:"level_id"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id 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
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var hallCount int
|
||||
err = tx.GetContext(ctx, &hallCount, `SELECT COUNT(*) 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=?
|
||||
WHERE h.tenant_id=? AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL`, member.LevelID, input.TenantID, input.HallID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hallCount == 0 {
|
||||
return nil, ErrLevelNotEligible
|
||||
}
|
||||
var wallet struct {
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
Status int `db:"account_status"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &wallet, `SELECT available_amount,account_status 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, ErrInsufficientBalance
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.Status != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.AvailableAmount < input.MinimumAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
var pending int64
|
||||
err = tx.GetContext(ctx, &pending, `SELECT id FROM hg_trade_order
|
||||
WHERE tenant_id=? AND member_id=? AND order_type=2 AND order_status IN (1,2,3,6)
|
||||
ORDER BY id DESC LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
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) {
|
||||
return nil, ErrLevelNotEligible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if 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 {
|
||||
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
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, "任务匹配成功,请在截止时间前完成")
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
orderID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||
where := ` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2`
|
||||
args := []any{tenantID, memberID}
|
||||
if status > 0 {
|
||||
where += ` AND o.order_status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_trade_order o`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
rows := make([]TaskOrder, 0)
|
||||
err := s.db.SelectContext(ctx, &rows, orderSelectSQL()+where+` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
return &TaskOrderPage{Items: rows, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrder(ctx context.Context, tenantID, memberID, orderID int64) (*TaskOrder, error) {
|
||||
var row TaskOrder
|
||||
err := s.db.GetContext(ctx, &row, orderSelectSQL()+` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2 AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func orderSelectSQL() string {
|
||||
return `SELECT o.id,o.order_no,o.task_id,o.product_id,COALESCE(NULLIF(o.product_name,''),t.title,'') AS product_name,
|
||||
COALESCE(NULLIF(p.cover_url,''),t.cover_url,'') AS cover_url,o.quantity,o.unit_price_amount,
|
||||
o.total_amount,o.commission_amount,o.currency,o.member_level_id,
|
||||
COALESCE(o.rule_snapshot,JSON_OBJECT()) AS rule_snapshot,o.message,o.order_status,o.freeze_status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
|
||||
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
|
||||
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
|
||||
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
|
||||
COALESCE(s.submission_status,0) AS submission_status,COALESCE(s.evidence_text,'') AS evidence_text,
|
||||
COALESCE(s.evidence_attachment_ids,JSON_ARRAY()) AS evidence_attachment_ids,
|
||||
COALESCE(s.review_reason_code,'') AS review_reason_code,COALESCE(s.review_reason,'') AS review_reason,
|
||||
COALESCE(s.allow_resubmit,2) AS allow_resubmit,
|
||||
COALESCE(DATE_FORMAT(s.created_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS submitted_at,
|
||||
COALESCE(DATE_FORMAT(s.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(s.settled_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS settled_at
|
||||
FROM hg_trade_order o LEFT JOIN hg_task t ON t.tenant_id=o.tenant_id AND t.id=o.task_id
|
||||
LEFT JOIN hg_product p ON p.tenant_id=o.tenant_id AND p.id=o.product_id
|
||||
LEFT JOIN hg_task_submission s ON s.tenant_id=o.tenant_id AND s.trade_order_id=o.id`
|
||||
}
|
||||
|
||||
func (s *Store) TaskStats(ctx context.Context, tenantID, memberID int64) (*TaskStats, error) {
|
||||
var row TaskStats
|
||||
err := s.db.GetContext(ctx, &row, `SELECT
|
||||
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
|
||||
(SELECT COALESCE(SUM(ts.commission_amount),0) FROM hg_task_submission ts WHERE ts.tenant_id=? AND ts.member_id=?
|
||||
AND ts.submission_status=4 AND ts.settled_at>=UTC_DATE() AND ts.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
|
||||
(SELECT COALESCE(SUM(ys.commission_amount),0) FROM hg_task_submission ys WHERE ys.tenant_id=? AND ys.member_id=?
|
||||
AND ys.submission_status=4 AND ys.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND ys.settled_at<UTC_DATE()) AS yesterday_commission_amount,
|
||||
(SELECT COUNT(*) FROM hg_trade_order tc WHERE tc.tenant_id=? AND tc.member_id=?
|
||||
AND tc.order_type=2 AND tc.created_at>=UTC_DATE() AND tc.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_claim_count,
|
||||
(SELECT COUNT(*) FROM hg_trade_order cc WHERE cc.tenant_id=? AND cc.member_id=? AND cc.order_type=2 AND cc.order_status=4
|
||||
AND cc.completed_at>=UTC_DATE() AND cc.completed_at<UTC_DATE()+INTERVAL 1 DAY) AS today_completed_count
|
||||
FROM hg_app_member m LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
|
||||
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*TaskSubmissionResult, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if order.DeadlineAt != "" {
|
||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||
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
|
||||
}
|
||||
if commitErr := tx.Commit(); commitErr != nil {
|
||||
return nil, commitErr
|
||||
}
|
||||
return nil, ErrOrderExpired
|
||||
}
|
||||
}
|
||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
(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)
|
||||
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 err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
||||
}
|
||||
|
||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
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 (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int
|
||||
if err := tx.GetContext(ctx, &count, query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(ids) {
|
||||
return ErrEvidenceInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, businessNumbers []string) ([]map[string]any, error) {
|
||||
if len(businessNumbers) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
|
||||
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger
|
||||
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
item := map[string]any{}
|
||||
if err := rows.MapScan(item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range item {
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
item[key] = string(bytes)
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
ID int64 `db:"id"`
|
||||
Code string `db:"channel_code"`
|
||||
Name string `db:"name"`
|
||||
ChannelType int `db:"channel_type"`
|
||||
MinAmount int64 `db:"min_amount"`
|
||||
MaxAmount int64 `db:"max_amount"`
|
||||
FeeType int `db:"fee_type"`
|
||||
FeeFixedAmount int64 `db:"fee_fixed_amount"`
|
||||
FeeRate int `db:"fee_rate"`
|
||||
}
|
||||
|
||||
type WithdrawalWallet struct {
|
||||
AccountID int64 `db:"account_id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
AccountStatus int `db:"account_status"`
|
||||
DailyLimit int `db:"daily_limit"`
|
||||
TodayCount int `db:"today_count"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type WithdrawalOrder struct {
|
||||
ID int64 `db:"id"`
|
||||
WithdrawalNo string `db:"withdrawal_no"`
|
||||
PaymentAccountID int64 `db:"payment_account_id"`
|
||||
ChannelID int64 `db:"channel_id"`
|
||||
ChannelName string `db:"channel_name"`
|
||||
RequestAmount int64 `db:"request_amount"`
|
||||
FeeAmount int64 `db:"fee_amount"`
|
||||
ActualAmount int64 `db:"actual_amount"`
|
||||
Currency string `db:"currency"`
|
||||
AccountMask string `db:"account_mask"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
RiskResult json.RawMessage `db:"risk_result"`
|
||||
Status int `db:"withdrawal_status"`
|
||||
ReviewReasonCode string `db:"review_reason_code"`
|
||||
ReviewReason string `db:"review_reason"`
|
||||
ExternalNo string `db:"external_no"`
|
||||
ReviewedAt string `db:"reviewed_at"`
|
||||
ProcessedAt string `db:"processed_at"`
|
||||
CompletedAt string `db:"completed_at"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
Replayed bool `db:"-"`
|
||||
}
|
||||
|
||||
type WithdrawalPage struct {
|
||||
Items []WithdrawalOrder
|
||||
Total int64
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
ORDER BY is_default ASC,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 {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
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 accountCount int
|
||||
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
|
||||
WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isDefault := 2
|
||||
if accountCount == 0 {
|
||||
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,
|
||||
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,
|
||||
isDefault, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPaymentAccountExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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,
|
||||
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`,
|
||||
tenantID, memberID, accountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrPaymentAccountInvalid
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) ActiveWithdrawalChannel(ctx context.Context, tenantID int64, channelTypes []int) (*WithdrawalChannel, error) {
|
||||
if len(channelTypes) == 0 {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
|
||||
WHERE tenant_id=? AND channel_type IN (?) AND direction IN (2,3) AND status=1 AND deleted_at IS NULL
|
||||
ORDER BY CASE WHEN channel_type=? THEN 0 ELSE 1 END,sort,id LIMIT 1`, tenantID, channelTypes, channelTypes[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row WithdrawalChannel
|
||||
err = s.db.GetContext(ctx, &row, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalWallet(ctx context.Context, tenantID, memberID int64) (*WithdrawalWallet, error) {
|
||||
var row WithdrawalWallet
|
||||
err := s.db.GetContext(ctx, &row, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
|
||||
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency='CNY' AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
|
||||
tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation) (*WithdrawalOrder, error) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, findErr := withdrawalByNo(ctx, tx, input.TenantID, input.MemberID, input.WithdrawalNo); findErr == nil {
|
||||
if existing.PaymentAccountID != input.PaymentAccountID || existing.RequestAmount != input.RequestAmount || existing.Currency != input.Currency {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
existing.Replayed = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
} else if !errors.Is(findErr, ErrWithdrawalNotFound) {
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
return nil, ErrPaymentAccountInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
|
||||
AND direction IN (2,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.Channel.ID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalChannel
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel != input.Channel {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
|
||||
return nil, ErrWithdrawalAmount
|
||||
}
|
||||
|
||||
var wallet WithdrawalWallet
|
||||
err = tx.GetContext(ctx, &wallet, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
|
||||
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
|
||||
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
|
||||
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
|
||||
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
|
||||
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
|
||||
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
|
||||
WHERE w.tenant_id=? AND w.member_id=? AND w.currency=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.Currency)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wallet.AccountStatus != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
|
||||
return nil, ErrWithdrawalLimit
|
||||
}
|
||||
if wallet.AvailableAmount < input.RequestAmount {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
|
||||
riskResult := json.RawMessage(`{"result":"passed","mode":"sandbox"}`)
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_withdrawal_order
|
||||
(tenant_id,withdrawal_no,member_id,payment_account_id,channel_id,request_amount,fee_amount,
|
||||
actual_amount,currency,account_snapshot_cipher,account_mask,bank_name,holder_mask,risk_result,
|
||||
withdrawal_status,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, input.TenantID, input.WithdrawalNo, input.MemberID,
|
||||
input.PaymentAccountID, channel.ID, input.RequestAmount, input.FeeAmount, input.ActualAmount,
|
||||
input.Currency, input.AccountSnapshotCipher, input.AccountMask, input.BankName, input.HolderMask,
|
||||
riskResult, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrIdempotencyConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
orderID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
beforeAvailable := wallet.AvailableAmount
|
||||
afterAvailable := beforeAvailable - input.RequestAmount
|
||||
beforeFrozen := wallet.FrozenAmount
|
||||
afterFrozen := beforeFrozen + input.RequestAmount
|
||||
update, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
|
||||
updated_at=UTC_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND available_amount=? AND frozen_amount=? AND account_status=1`,
|
||||
afterAvailable, afterFrozen, wallet.AccountID, input.TenantID, beforeAvailable, beforeFrozen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := update.RowsAffected(); affected != 1 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
availableLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.AvailableLedgerNo,
|
||||
"available", 2, beforeAvailable, afterAvailable, "available", "取出申请冻结可用余额")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = availableLedgerID
|
||||
frozenLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.FrozenLedgerNo,
|
||||
"frozen", 1, beforeFrozen, afterFrozen, "frozen", "取出申请增加冻结余额")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE hg_withdrawal_order SET freeze_ledger_id=? WHERE tenant_id=? AND id=?`,
|
||||
frozenLedgerID, input.TenantID, orderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WithdrawalOrder{ID: orderID, WithdrawalNo: input.WithdrawalNo,
|
||||
PaymentAccountID: input.PaymentAccountID, ChannelID: channel.ID, ChannelName: channel.Name,
|
||||
RequestAmount: input.RequestAmount, FeeAmount: input.FeeAmount, ActualAmount: input.ActualAmount,
|
||||
Currency: input.Currency, AccountMask: input.AccountMask, BankName: input.BankName,
|
||||
HolderMask: input.HolderMask, RiskResult: riskResult, Status: 1}, nil
|
||||
}
|
||||
|
||||
func insertWithdrawalLedger(ctx context.Context, tx *sqlx.Tx, input WithdrawalCreation, accountID int64,
|
||||
ledgerNo, entryType string, direction int, beforeAmount, afterAmount int64, suffix, 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(?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,UTC_TIMESTAMP(3))`, input.TenantID, ledgerNo, accountID,
|
||||
input.MemberID, "withdraw_freeze", input.WithdrawalNo, entryType, direction, input.RequestAmount,
|
||||
beforeAmount, afterAmount, input.Currency, "withdrawal:"+input.WithdrawalNo+":"+suffix, remark, input.MemberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*WithdrawalPage, error) {
|
||||
where := ` WHERE o.tenant_id=? AND o.member_id=?`
|
||||
args := []any{tenantID, memberID}
|
||||
if status > 0 {
|
||||
where += ` AND o.withdrawal_status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_withdrawal_order o`+where, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
items := make([]WithdrawalOrder, 0)
|
||||
err := s.db.SelectContext(ctx, &items, withdrawalSelectSQL()+where+
|
||||
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
return &WithdrawalPage{Items: items, Total: total}, err
|
||||
}
|
||||
|
||||
func (s *Store) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*WithdrawalOrder, error) {
|
||||
var row WithdrawalOrder
|
||||
err := s.db.GetContext(ctx, &row, withdrawalSelectSQL()+
|
||||
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func withdrawalByNo(ctx context.Context, db queryer, tenantID, memberID int64, no string) (*WithdrawalOrder, error) {
|
||||
query := withdrawalSelectSQL() + ` WHERE o.tenant_id=? AND o.member_id=? AND o.withdrawal_no=? LIMIT 1`
|
||||
var row WithdrawalOrder
|
||||
err := db.GetContext(ctx, &row, query, tenantID, memberID, no)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrWithdrawalNotFound
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func withdrawalSelectSQL() string {
|
||||
return `SELECT o.id,o.withdrawal_no,o.payment_account_id,o.channel_id,COALESCE(c.name,'') AS channel_name,
|
||||
o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask,
|
||||
COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code,
|
||||
o.review_reason,o.external_no,
|
||||
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
|
||||
COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
|
||||
}
|
||||
|
||||
func maxInt64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tasks
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrHallIDInvalid = errors.New("hall id invalid")
|
||||
ErrOrderIDInvalid = errors.New("order id invalid")
|
||||
ErrPageInvalid = errors.New("page invalid")
|
||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||
ErrStatusInvalid = errors.New("status invalid")
|
||||
ErrEvidenceInvalid = errors.New("evidence invalid")
|
||||
ErrTaskDisabled = errors.New("task feature disabled")
|
||||
ErrOutsideBusinessHour = errors.New("outside business hours")
|
||||
)
|
||||
@@ -0,0 +1,553 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
maxPageSize = 100
|
||||
defaultDeadlineMinute = 15
|
||||
)
|
||||
|
||||
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
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)
|
||||
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)
|
||||
SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error)
|
||||
OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type Hall struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IconURL string `json:"icon_url"`
|
||||
Summary string `json:"summary"`
|
||||
CommissionRate int `json:"commission_rate"`
|
||||
RateUnit string `json:"rate_unit"`
|
||||
ClientRoute string `json:"client_route"`
|
||||
}
|
||||
|
||||
type Eligibility struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
MinimumAmount int64 `json:"minimum_amount"`
|
||||
TodayCount int `json:"today_count"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
PendingOrderID int64 `json:"pending_order_id"`
|
||||
BusinessStartAt string `json:"business_start_at"`
|
||||
BusinessEndAt string `json:"business_end_at"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID int64 `json:"id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
Product Product `json:"product"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPriceAmount int64 `json:"unit_price_amount"`
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
CommissionAmount int64 `json:"commission_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
Message string `json:"message"`
|
||||
DeadlineAt string `json:"deadline_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Task *TaskDetail `json:"task,omitempty"`
|
||||
RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"`
|
||||
Submission *Submission `json:"submission,omitempty"`
|
||||
Timeline []TimelineEntry `json:"timeline,omitempty"`
|
||||
Ledgers []map[string]any `json:"ledgers,omitempty"`
|
||||
AllowedActions []string `json:"allowed_actions,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DetailHTML string `json:"detail_html"`
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
SubmissionNo string `json:"submission_no"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
EvidenceText string `json:"evidence_text"`
|
||||
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
||||
ReviewReasonCode string `json:"review_reason_code"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
AllowResubmit bool `json:"allow_resubmit"`
|
||||
SubmittedAt string `json:"submitted_at"`
|
||||
ReviewedAt string `json:"reviewed_at"`
|
||||
SettledAt string `json:"settled_at"`
|
||||
}
|
||||
|
||||
type TimelineEntry struct {
|
||||
Status string `json:"status"`
|
||||
Text string `json:"text"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type OrderPage struct {
|
||||
Items []Order `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
TotalAssetAmount int64 `json:"total_asset_amount"`
|
||||
TodayCommissionAmount int64 `json:"today_commission_amount"`
|
||||
YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"`
|
||||
TodayClaimCount int `json:"today_claim_count"`
|
||||
TodayCompletedCount int `json:"today_completed_count"`
|
||||
}
|
||||
|
||||
type SubmitInput struct {
|
||||
EvidenceText string `json:"evidence_text"`
|
||||
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
|
||||
}
|
||||
|
||||
type SubmitResult struct {
|
||||
SubmissionNo string `json:"submission_no"`
|
||||
Status string `json:"status"`
|
||||
SubmittedAt string `json:"submitted_at"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
}
|
||||
|
||||
type taskPolicy struct {
|
||||
Enabled bool
|
||||
MinimumAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
return &Service{repository: repository, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) {
|
||||
rows, err := s.repository.TaskHalls(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Hall, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
|
||||
Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) {
|
||||
if hallID <= 0 {
|
||||
return nil, ErrHallIDInvalid
|
||||
}
|
||||
policy, err := s.policy(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
|
||||
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
|
||||
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"}
|
||||
switch {
|
||||
case !policy.Enabled:
|
||||
result.reject("TASK_DISABLED", "任务功能未开启")
|
||||
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
|
||||
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
|
||||
case row.MemberStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case !row.HallEligible:
|
||||
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
||||
case row.WalletStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case row.AvailableAmount < policy.MinimumAmount:
|
||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
||||
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
||||
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
||||
case row.PendingOrderID > 0:
|
||||
result.reject("PENDING_ORDER_EXISTS", "存在未完成订单")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Eligibility) reject(reason, message string) {
|
||||
e.Eligible = false
|
||||
e.Reason = reason
|
||||
e.Message = message
|
||||
}
|
||||
|
||||
func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) {
|
||||
if hallID <= 0 {
|
||||
return nil, ErrHallIDInvalid
|
||||
}
|
||||
policy, err := s.policy(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !policy.Enabled {
|
||||
return nil, ErrTaskDisabled
|
||||
}
|
||||
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
|
||||
return nil, ErrOutsideBusinessHour
|
||||
}
|
||||
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)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := orderFromStore(*row, false)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultPageSize
|
||||
}
|
||||
if page < 1 {
|
||||
return nil, ErrPageInvalid
|
||||
}
|
||||
if pageSize < 1 || pageSize > maxPageSize {
|
||||
return nil, ErrPageSizeInvalid
|
||||
}
|
||||
statusCode, ok := orderStatusCode(status)
|
||||
if !ok {
|
||||
return nil, ErrStatusInvalid
|
||||
}
|
||||
rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]Order, 0, len(rows.Items))
|
||||
for _, row := range rows.Items {
|
||||
items = append(items, orderFromStore(row, false))
|
||||
}
|
||||
return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
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
|
||||
}
|
||||
result := orderFromStore(*row, true)
|
||||
result.Ledgers = ledgers
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) {
|
||||
row, err := s.repository.TaskStats(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
|
||||
TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount,
|
||||
YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount,
|
||||
TodayCompletedCount: row.TodayCompletedCount}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) {
|
||||
if orderID <= 0 {
|
||||
return nil, ErrOrderIDInvalid
|
||||
}
|
||||
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) {
|
||||
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})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
||||
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) {
|
||||
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
|
||||
if err != nil {
|
||||
return taskPolicy{}, err
|
||||
}
|
||||
var config map[string]any
|
||||
if json.Unmarshal(raw, &config) != nil {
|
||||
config = map[string]any{}
|
||||
}
|
||||
minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes"))
|
||||
if minutes <= 0 || minutes > 24*60 {
|
||||
minutes = defaultDeadlineMinute
|
||||
}
|
||||
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00")
|
||||
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"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
func orderFromStore(row store.TaskOrder, detail bool) Order {
|
||||
status := orderStatusName(row.OrderStatus)
|
||||
result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID,
|
||||
Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity,
|
||||
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}
|
||||
if !detail {
|
||||
return result
|
||||
}
|
||||
result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML}
|
||||
result.RuleSnapshot = row.RuleSnapshot
|
||||
result.Timeline = orderTimeline(row)
|
||||
result.AllowedActions = allowedActions(row)
|
||||
if row.SubmissionNo != "" {
|
||||
ids := make([]int64, 0)
|
||||
_ = json.Unmarshal(row.EvidenceIDs, &ids)
|
||||
result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus),
|
||||
StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText,
|
||||
EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason,
|
||||
AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func orderTimeline(row store.TaskOrder) []TimelineEntry {
|
||||
items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}}
|
||||
if row.MatchedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt})
|
||||
}
|
||||
if row.SubmittedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt})
|
||||
}
|
||||
if row.ReviewedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt})
|
||||
}
|
||||
if row.SettledAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt})
|
||||
}
|
||||
if row.CompletedAt != "" {
|
||||
items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func allowedActions(row store.TaskOrder) []string {
|
||||
switch row.OrderStatus {
|
||||
case 2:
|
||||
return []string{"submit", "contact_support"}
|
||||
case 3, 5, 6, 8:
|
||||
return []string{"contact_support"}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func orderStatusCode(value string) (int, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "all":
|
||||
return 0, true
|
||||
case "matching":
|
||||
return 1, true
|
||||
case "pending":
|
||||
return 2, true
|
||||
case "processing":
|
||||
return 3, true
|
||||
case "completed":
|
||||
return 4, true
|
||||
case "expired":
|
||||
return 5, true
|
||||
case "frozen":
|
||||
return 6, true
|
||||
case "cancelled":
|
||||
return 7, true
|
||||
case "failed":
|
||||
return 8, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func orderStatusName(status int) string {
|
||||
names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"}
|
||||
if value, ok := names[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func orderStatusText(status int) string {
|
||||
names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"}
|
||||
if value, ok := names[status]; ok {
|
||||
return value
|
||||
}
|
||||
return "未知状态"
|
||||
}
|
||||
|
||||
func submissionStatusName(status int) string {
|
||||
names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"}
|
||||
return names[status]
|
||||
}
|
||||
|
||||
func submissionStatusText(status int) string {
|
||||
names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"}
|
||||
return names[status]
|
||||
}
|
||||
|
||||
func withinBusinessHours(now time.Time, start, end string) bool {
|
||||
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
|
||||
startSecond := parseClock(start)
|
||||
endSecond := parseClock(end)
|
||||
if startSecond <= endSecond {
|
||||
return current >= startSecond && current <= endSecond
|
||||
}
|
||||
return current >= startSecond || current <= endSecond
|
||||
}
|
||||
|
||||
func parseClock(value string) int {
|
||||
parsed, err := time.Parse("15:04:05", value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
|
||||
}
|
||||
|
||||
func clockValue(value any, fallback string) string {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
for _, layout := range []string{"15:04:05", "15:04"} {
|
||||
if parsed, err := time.Parse(layout, raw); err == nil {
|
||||
return parsed.Format("15:04:05")
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func findValue(config map[string]any, keys ...string) any {
|
||||
for _, key := range keys {
|
||||
for candidate, value := range config {
|
||||
if strings.EqualFold(candidate, key) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range config {
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
if found := findValue(nested, keys...); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed == 1
|
||||
case string:
|
||||
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value any) int {
|
||||
return int(int64Value(value))
|
||||
}
|
||||
|
||||
func int64Value(value any) int64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return int64(typed)
|
||||
case json.Number:
|
||||
parsed, _ := typed.Int64()
|
||||
return parsed
|
||||
case string:
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func max64(left, right int64) int64 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func validUniqueIDs(ids []int64) bool {
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return false
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return false
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newNumber(prefix string, now time.Time) string {
|
||||
bytes := make([]byte, 4)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000)
|
||||
}
|
||||
return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
eligibility *store.TaskEligibility
|
||||
order *store.TaskOrder
|
||||
claimed store.ClaimTaskInput
|
||||
submitted store.TaskSubmissionInput
|
||||
}
|
||||
|
||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
||||
return f.config, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) {
|
||||
return []store.TaskHall{{ID: 1, Code: "DEFAULT", Name: "任务大厅"}}, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) {
|
||||
return f.eligibility, nil
|
||||
}
|
||||
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",
|
||||
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, 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
|
||||
}
|
||||
func (f *fakeRepository) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) {
|
||||
return f.order, nil
|
||||
}
|
||||
func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) {
|
||||
return &store.TaskStats{AvailableAmount: 100, FrozenAmount: 25}, nil
|
||||
}
|
||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||
f.submitted = input
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
|
||||
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) {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
|
||||
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}}
|
||||
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" || result.MinimumAmount != 50000 {
|
||||
t.Fatalf("unexpected eligibility: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
||||
service := New(repository)
|
||||
|
||||
_, err := service.Claim(context.Background(), 0, 10, 1)
|
||||
if !errors.Is(err, ErrTaskDisabled) {
|
||||
t.Fatalf("expected task disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimBuildsPendingOrder(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)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
result, err := service.Claim(context.Background(), 0, 10, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending" || 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) {
|
||||
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}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
|
||||
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||
if !errors.Is(err, ErrStatusInvalid) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
end_of_line=LF
|
||||
insert_final_newline=true
|
||||
indent_style=space
|
||||
indent_size=2
|
||||
max_line_length = 100
|
||||
|
||||
[*.{yml,yaml,json}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,3 @@
|
||||
VITE_PUBLIC_PATH=/
|
||||
VITE_PORT=5173
|
||||
VITE_API_TARGET=http://localhost:6061
|
||||
@@ -0,0 +1 @@
|
||||
VITE_PUBLIC_PATH=/
|
||||
@@ -0,0 +1,30 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/screenshots
|
||||
/dist
|
||||
dist.zip
|
||||
dist_electron
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
/components.d.ts
|
||||
|
||||
/build/
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>Olons</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "olons-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"axios": "^1.7.7",
|
||||
"naive-ui": "^2.44.1",
|
||||
"vue": "^3.4.38",
|
||||
"vue-router": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.2",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
Generated
+1210
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
'@swc/core': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createDiscreteApi, NIcon, NModal, NSpin } from 'naive-ui';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './mobile/styles.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
window.$message = message;
|
||||
app.component('NIcon', NIcon);
|
||||
app.component('NModal', NModal);
|
||||
app.component('NSpin', NSpin);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/app/home',
|
||||
},
|
||||
{
|
||||
path: '/app/auth',
|
||||
name: 'MobileAuth',
|
||||
component: () => import('../mobile/views/AuthView.vue'),
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
component: () => import('../mobile/MobileShell.vue'),
|
||||
redirect: '/app/home',
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
name: 'MobileHome',
|
||||
component: () => import('../mobile/views/HomeView.vue'),
|
||||
meta: { title: '首页', mobileNav: true },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'MobileOrders',
|
||||
component: () => import('../mobile/views/OrdersView.vue'),
|
||||
meta: { title: '记录', mobileNav: true },
|
||||
},
|
||||
{
|
||||
path: 'orders/:id',
|
||||
name: 'MobileOrderDetail',
|
||||
component: () => import('../mobile/views/OrdersView.vue'),
|
||||
meta: { title: '详情页' },
|
||||
},
|
||||
{
|
||||
path: 'claim',
|
||||
name: 'MobileClaim',
|
||||
component: () => import('../mobile/views/ClaimView.vue'),
|
||||
meta: { title: '领取', mobileNav: true },
|
||||
},
|
||||
{
|
||||
path: 'leaderboard',
|
||||
name: 'MobileLeaderboard',
|
||||
component: () => import('../mobile/views/LeaderboardView.vue'),
|
||||
meta: { title: '荣誉榜' },
|
||||
},
|
||||
{
|
||||
path: 'mall',
|
||||
name: 'MobileMall',
|
||||
component: () => import('../mobile/views/MallView.vue'),
|
||||
meta: { title: '商城' },
|
||||
},
|
||||
{
|
||||
path: 'mall/:id',
|
||||
name: 'MobileProduct',
|
||||
component: () => import('../mobile/views/MallView.vue'),
|
||||
meta: { title: '商品详情' },
|
||||
},
|
||||
{
|
||||
path: 'support',
|
||||
name: 'MobileSupport',
|
||||
component: () => import('../mobile/views/SupportView.vue'),
|
||||
meta: { title: '客服中心', mobileNav: true },
|
||||
},
|
||||
{
|
||||
path: 'support/:id',
|
||||
name: 'MobileChat',
|
||||
component: () => import('../mobile/views/SupportView.vue'),
|
||||
meta: { title: '在线客服' },
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'MobileProfile',
|
||||
component: () => import('../mobile/views/ProfileView.vue'),
|
||||
meta: { title: '我的', mobileNav: true },
|
||||
},
|
||||
{
|
||||
path: 'profile/info',
|
||||
name: 'MobileProfileInfo',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '个人信息', form: 'profile' },
|
||||
},
|
||||
{
|
||||
path: 'profile/bank-card',
|
||||
name: 'MobileBankCard',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '添加银行卡', form: 'bank' },
|
||||
},
|
||||
{
|
||||
path: 'profile/withdrawal',
|
||||
name: 'MobileWithdrawal',
|
||||
component: () => import('../mobile/views/WithdrawalView.vue'),
|
||||
meta: { title: '取出' },
|
||||
},
|
||||
{
|
||||
path: 'recharge',
|
||||
name: 'MobileRecharge',
|
||||
component: () => import('../mobile/views/RechargeView.vue'),
|
||||
meta: { title: '储入' },
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
name: 'MobileRules',
|
||||
component: () => import('../mobile/views/RulesView.vue'),
|
||||
meta: { title: '规则说明' },
|
||||
},
|
||||
{
|
||||
path: 'agency',
|
||||
name: 'MobileAgency',
|
||||
component: () => import('../mobile/views/AgencyView.vue'),
|
||||
meta: { title: '代理合作' },
|
||||
},
|
||||
{
|
||||
path: 'profile/records',
|
||||
name: 'MobileFinanceRecords',
|
||||
component: () => import('../mobile/views/FinanceRecordsView.vue'),
|
||||
meta: { title: '记录' },
|
||||
},
|
||||
{
|
||||
path: 'profile/password',
|
||||
name: 'MobileLoginPassword',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '修改登录密码', form: 'loginPassword' },
|
||||
},
|
||||
{
|
||||
path: 'profile/trade-password',
|
||||
name: 'MobileTradePassword',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '支付密码', form: 'tradePassword' },
|
||||
},
|
||||
{
|
||||
path: 'password/verify',
|
||||
name: 'MobilePasswordVerify',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '身份验证', form: 'verify' },
|
||||
},
|
||||
{
|
||||
path: 'password/reset',
|
||||
name: 'MobilePasswordReset',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '重设密码', form: 'reset' },
|
||||
},
|
||||
{
|
||||
path: 'content/platform_intro',
|
||||
name: 'MobilePlatformIntro',
|
||||
component: () => import('../mobile/views/PlatformIntroView.vue'),
|
||||
meta: { title: '平台介绍' },
|
||||
},
|
||||
{
|
||||
path: 'content/:key',
|
||||
name: 'MobileContent',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '内容详情', form: 'content' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/app/home',
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
router.afterEach((route) => {
|
||||
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), 'VITE_');
|
||||
|
||||
return {
|
||||
base: env.VITE_PUBLIC_PATH || '/',
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: Number(env.VITE_PORT) || 5173,
|
||||
proxy: {
|
||||
'/v1': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2015',
|
||||
outDir: 'dist',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user