feat: 完善移动端会员业务流程
This commit is contained in:
@@ -26,6 +26,8 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
.idea/
|
||||
.DS_Store
|
||||
storage/uploads/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
|
||||
+5
-5
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
COPY --from=web-builder /web/dist ./web/dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
||||
COPY --from=builder /out/im_workbench /app/im_workbench
|
||||
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
|
||||
COPY --from=builder /out/olons_server /app/olons_server
|
||||
COPY configs /app/configs
|
||||
COPY --from=web-builder /web/dist /app/web/dist
|
||||
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
|
||||
ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
|
||||
EXPOSE 6061
|
||||
ENTRYPOINT ["/app/im_workbench"]
|
||||
ENTRYPOINT ["/app/olons_server"]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
.PHONY: build test run web-install web-build
|
||||
|
||||
build:
|
||||
go build -o bin/im_workbench ./cmd/workbench
|
||||
go build -o bin/olons_server ./cmd/olons_server
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
run:
|
||||
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
|
||||
OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
|
||||
|
||||
web-install:
|
||||
cd web && pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Workbench
|
||||
# Olons Server
|
||||
|
||||
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
|
||||
|
||||
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
||||
项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
|
||||
|
||||
## 当前接口
|
||||
|
||||
@@ -42,17 +42,16 @@
|
||||
核心环境变量:
|
||||
|
||||
```bash
|
||||
WORKBENCH_CONFIG=configs/workbench.local.json
|
||||
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
|
||||
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
|
||||
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
|
||||
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
|
||||
OLONS_SERVER_CONFIG=configs/olons_server.local.json
|
||||
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
|
||||
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
|
||||
OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
|
||||
```
|
||||
|
||||
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
||||
`finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
|
||||
|
||||
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
||||
前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
|
||||
|
||||
```bash
|
||||
make web-install
|
||||
@@ -60,7 +59,7 @@ make web-build
|
||||
make run
|
||||
```
|
||||
|
||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
|
||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
|
||||
|
||||
## 请求头
|
||||
|
||||
@@ -70,7 +69,7 @@ X-Request-ID: <request id>
|
||||
X-Device-ID: <optional device id>
|
||||
```
|
||||
|
||||
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
@@ -59,9 +59,9 @@ func main() {
|
||||
log.Fatal("mobile_cipher is missing before migration")
|
||||
}
|
||||
|
||||
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
|
||||
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/app"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/app"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,7 +34,7 @@ func main() {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("workbench listening on %s", cfg.Listen)
|
||||
log.Printf("olons_server listening on %s", cfg.Listen)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
@@ -10,13 +10,16 @@
|
||||
"addr": "127.0.0.1:6379",
|
||||
"db": 0
|
||||
},
|
||||
"upload": {
|
||||
"dir": "storage/uploads",
|
||||
"public_path": "/uploads"
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"allow_dev_header_auth": true,
|
||||
"required_api_key": false
|
||||
}
|
||||
@@ -10,13 +10,16 @@
|
||||
"addr": "",
|
||||
"db": 0
|
||||
},
|
||||
"upload": {
|
||||
"dir": "storage/uploads",
|
||||
"public_path": "/uploads"
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"required_api_key": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 718 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
+28
-32
@@ -2,12 +2,12 @@
|
||||
|
||||
> 版本:V1.0
|
||||
> 更新日期:2026-10-09
|
||||
> 适用项目:`im_workbench`
|
||||
> 适用项目:`olons_server`
|
||||
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
|
||||
|
||||
## 1. 文档说明
|
||||
|
||||
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
||||
本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
||||
|
||||
接口分为两类:
|
||||
|
||||
@@ -303,7 +303,7 @@ PATCH /v1/me/avatar
|
||||
}
|
||||
```
|
||||
|
||||
附件必须是 `app_id=workbench`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
|
||||
附件必须是 `app_id=olons_server`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
|
||||
|
||||
成功响应 `data`:
|
||||
|
||||
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
|
||||
|
||||
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
|
||||
|
||||
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。当前版本在事务内直接选中可用任务,创建 `status=pending` 的订单并记录 `matched_at`,客户端收到成功响应后即可进入订单详情并提交凭证;不依赖额外匹配进程。领取不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`,任务名称和封面取自 `hg_task`。
|
||||
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、会员等级每日领取次数和未完成订单。校验通过后先创建 `status=matching`、`order_type=2` 的派单中订单,此时商品、数量、金额和佣金均为空或 `0`,与后台订单列表的“派单中/匹配订单”原型一致。会员的 `auto_dispatch=1` 时,达到网站设置的 `autoOrderDelaySeconds` 延时后由系统自动匹配:从当前已发布、在有效期内、币种为 CNY,且价格同时满足会员可用余额与 `autoOrderMinAmount`/`autoOrderMaxAmount` 区间的 `hg_product` 中随机选取商品,按会员当前等级版本计算佣金,并将订单原子更新为 `status=pending`。匹配成功时在同一事务内从可用余额扣除订单本金并转入冻结余额,同时生成可用余额支出和冻结余额收入流水。订单完成时释放冻结本金、返回可用余额并另行入账佣金;订单超时时也必须释放并退回本金。`auto_dispatch=2` 或自动匹配暂时找不到商品时,订单保持“派单中”,管理员可在后台通过“匹配订单”人工指定商品,人工匹配也执行相同的本金扣除和冻结流程。自动和人工匹配共用同一状态条件,并发时只有一方能更新成功。新注册会员默认开启自动派单,管理员可在会员编辑弹窗修改派单模式。
|
||||
|
||||
| HTTP | `reason` | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"evidence_text": "已完成任务",
|
||||
"evidence_attachment_ids": [1001, 1002]
|
||||
}
|
||||
{}
|
||||
```
|
||||
|
||||
成功返回 `submission_no`、`status=reviewing`、`submitted_at` 和最新订单状态。重复提交不得生成多笔审核记录或佣金流水。
|
||||
成功返回 `submission_no`、`status=settled`、`submitted_at` 和 `order_status=completed`。重复提交不得生成多笔提交记录或佣金流水。
|
||||
|
||||
仅 `pending` 订单允许提交。`evidence_text` 去除首尾空白后最长 2000 字符,附件最多 9 个,两者至少提供一项;附件必须属于当前租户和当前会员、`app_id=workbench`、状态有效,且类型为图片或 PDF。提交事务对订单加锁,超时订单自动置为 `expired`,有效提交创建唯一审核记录并把订单置为 `processing`。此步骤不生成佣金流水;审核通过并结算仍由后台流程负责。
|
||||
正常情况下仅 `pending` 订单允许提交。前端按原型提供“立即提交”确认操作,不要求用户填写凭证。接口仍兼容可选的 `evidence_text` 和 `evidence_attachment_ids`:文字最长 2000 字符、附件最多 9 个;附件必须属于当前租户和当前会员、`app_id=olons_server`、状态有效,且类型为图片或 PDF。提交事务对订单、钱包加锁,超时订单自动置为 `expired`;有效提交创建唯一的已结算记录、佣金流水并增加可用余额,最后把订单置为 `completed`。后台审核页保留已结算记录供查询,冻结、强制处理和人工补偿只作为异常兜底。
|
||||
|
||||
订单详情返回:
|
||||
|
||||
@@ -659,7 +656,7 @@ POST /v1/task-orders/{order_id}/submissions
|
||||
- `submission`:凭证、审核状态与驳回原因;
|
||||
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
|
||||
- `ledgers`:以订单号或提交号关联的钱包流水;
|
||||
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。
|
||||
- `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
|
||||
|
||||
### 7.6 任务统计
|
||||
|
||||
@@ -857,7 +854,7 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
|
||||
|
||||
响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
|
||||
|
||||
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
|
||||
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。当前人工审核流程创建后直接进入 `reviewing`;`pending_payment` 仅为后续接入真实支付通道和支付回调时保留。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
|
||||
|
||||
## 11. 收款账户和取出(已实现,沙箱)
|
||||
|
||||
@@ -870,36 +867,34 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
|
||||
```http
|
||||
GET /v1/payment-accounts
|
||||
POST /v1/payment-accounts
|
||||
POST /v1/attachments/images
|
||||
```
|
||||
|
||||
新增请求:
|
||||
先分别上传微信和支付宝二维码。图片接口使用 `multipart/form-data`,字段名为 `file`,仅接受不超过 5MB 的 JPG、PNG、WebP,返回附件 `id` 和可访问的 `url`。随后提交新增请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"account_type": "bank_card",
|
||||
"bank_code": "ICBC",
|
||||
"bank_name": "中国工商银行",
|
||||
"holder_name": "张三",
|
||||
"identity_no": "11010119900101123X",
|
||||
"account_no": "6222000000001234",
|
||||
"reserved_phone": "13800138000",
|
||||
"qr_attachment_id": 0,
|
||||
"trade_password": "654321"
|
||||
"wechat_qr_attachment_id": 101,
|
||||
"alipay_qr_attachment_id": 102
|
||||
}
|
||||
```
|
||||
|
||||
支持的账户类型:
|
||||
绑卡页面按原型固定为银行卡账户,字段规则如下:
|
||||
|
||||
| `account_type` | 必填字段 | 说明 |
|
||||
| 字段 | 是否必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `bank_card` | `bank_code`、`bank_name`、`holder_name`、`account_no` | 卡号只允许 8–32 位数字,空格和连字符会被移除 |
|
||||
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 |
|
||||
| `unionpay` | `holder_name`、`account_no` | 银联账户 |
|
||||
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
||||
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
||||
| `holder_name` | 是 | 2–64 个字符 |
|
||||
| `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
|
||||
| `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
|
||||
| `bank_name` | 是 | 2–100 个字符 |
|
||||
| `wechat_qr_attachment_id` | 是 | 当前会员上传的有效图片附件 |
|
||||
| `alipay_qr_attachment_id` | 是 | 当前会员上传的有效图片附件,不能与微信二维码共用同一附件 |
|
||||
|
||||
`reserved_phone` 可选,传入时必须为 11 位中国大陆手机号;`qr_attachment_id` 可选,传入时必须是当前会员在 `workbench` 应用下有效的图片附件。绑定前必须校验 6 位数字交易密码,连续错误 5 次后锁定 15 分钟。
|
||||
|
||||
姓名、账号和手机号使用 AES-256-GCM 独立随机 nonce 加密,账号另存 HMAC-SHA256 检索哈希用于防重复。列表只返回脱敏信息,绝不返回明文或密文。新增账户初始 `verify_status=pending`,必须由后台核验为 `verified` 后才能用于取出。
|
||||
姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
|
||||
|
||||
列表项示例:
|
||||
|
||||
@@ -907,13 +902,14 @@ POST /v1/payment-accounts
|
||||
{
|
||||
"id": 1,
|
||||
"account_type": "bank_card",
|
||||
"bank_code": "ICBC",
|
||||
"bank_code": "BANK",
|
||||
"bank_name": "中国工商银行",
|
||||
"holder_mask": "张*",
|
||||
"mobile_mask": "138****8000",
|
||||
"identity_no_mask": "1101**********123X",
|
||||
"account_no_mask": "6222********1234",
|
||||
"qr_attachment_id": 0,
|
||||
"verify_status": "pending",
|
||||
"wechat_qr_attachment_id": 101,
|
||||
"alipay_qr_attachment_id": 102,
|
||||
"verify_status": "verified",
|
||||
"is_default": true,
|
||||
"created_at": "2026-10-09T10:00:00Z",
|
||||
"updated_at": "2026-10-09T10:00:00Z"
|
||||
|
||||
@@ -111,9 +111,9 @@
|
||||
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
||||
| --- | --- | --- | --- |
|
||||
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
||||
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
|
||||
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
|
||||
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
|
||||
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
|
||||
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
|
||||
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
|
||||
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
||||
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module git.118im.com/im_team/im_workbench
|
||||
module git.118im.com/im_team/olons_server
|
||||
|
||||
go 1.26.0
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -355,7 +355,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
||||
|
||||
func (s *Service) loginRateKey(phone string) string {
|
||||
digest := sha256.Sum256([]byte(phone))
|
||||
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
|
||||
+17
-14
@@ -8,15 +8,16 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/httpapi"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/httpapi"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ type App struct {
|
||||
redis *redis.Client
|
||||
api *httpapi.API
|
||||
frontendPath string
|
||||
upload config.UploadConfig
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
@@ -49,14 +51,15 @@ func New(cfg *config.Config) (*App, error) {
|
||||
taskService := tasks.New(st)
|
||||
mallService := mall.New(st)
|
||||
financeService := finance.New(st, redisClient, cfg.Auth)
|
||||
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService), frontendPath: cfg.FrontendDir}, nil
|
||||
attachmentService := attachments.New(st, cfg.Upload)
|
||||
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(strings.TrimRight(a.upload.PublicPath, "/")+"/", http.StripPrefix(strings.TrimRight(a.upload.PublicPath, "/"), http.FileServer(http.Dir(a.upload.Dir))))
|
||||
if a.frontendDir() != "" {
|
||||
mux.Handle("/admin", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/", frontendHandler(a.frontendDir()))
|
||||
}
|
||||
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
@@ -65,7 +68,7 @@ func (a *App) Handler() http.Handler {
|
||||
}
|
||||
|
||||
func (a *App) frontendDir() string {
|
||||
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
|
||||
if dir := strings.TrimSpace(os.Getenv("OLONS_SERVER_FRONTEND_DIR")); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return strings.TrimSpace(a.frontendPath)
|
||||
@@ -75,7 +78,7 @@ func frontendHandler(dir string) http.Handler {
|
||||
files := http.FileServer(http.Dir(dir))
|
||||
index := filepath.Join(dir, "index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const maxImageSize = 5 << 20
|
||||
|
||||
var ErrImageInvalid = errors.New("image attachment invalid")
|
||||
|
||||
type Repository interface {
|
||||
CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
cfg config.UploadConfig
|
||||
}
|
||||
|
||||
func New(repository Repository, cfg config.UploadConfig) *Service {
|
||||
return &Service{repository: repository, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
|
||||
if s.repository == nil || memberID <= 0 {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1))
|
||||
if err != nil || len(data) == 0 || len(data) > maxImageSize {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
mimeType := http.DetectContentType(data)
|
||||
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
ext, ok := extensions[mimeType]
|
||||
if !ok {
|
||||
return nil, ErrImageInvalid
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err = rand.Read(random); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileName := hex.EncodeToString(random) + ext
|
||||
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(s.cfg.Dir, fileName)
|
||||
if err = os.WriteFile(path, data, 0o640); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := md5.Sum(data)
|
||||
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
|
||||
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
|
||||
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path,
|
||||
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
|
||||
}
|
||||
|
||||
func memberRevokedBeforeKey(tenantID, memberID int64) string {
|
||||
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
return "olons_server:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
|
||||
}
|
||||
|
||||
func sessionKeyFromToken(pattern, token string) (string, error) {
|
||||
|
||||
@@ -9,18 +9,18 @@ import (
|
||||
|
||||
func TestSessionKeyFromToken(t *testing.T) {
|
||||
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
|
||||
key, err := sessionKeyFromToken("olons_server:session:token:%s", token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 {
|
||||
if !strings.HasPrefix(key, "olons_server:session:token:") || len(key) != len("olons_server:session:token:")+64 {
|
||||
t.Fatalf("unexpected session key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
|
||||
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
|
||||
if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
||||
if _, err := sessionKeyFromToken("olons_server:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
|
||||
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-19
@@ -18,10 +18,16 @@ type Config struct {
|
||||
MySQL MySQLConfig `json:"mysql"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
Upload UploadConfig `json:"upload"`
|
||||
ReadTimeout time.Duration `json:"-"`
|
||||
WriteTimeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
type UploadConfig struct {
|
||||
Dir string `json:"dir"`
|
||||
PublicPath string `json:"public_path"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
DSN string `json:"dsn"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
@@ -42,13 +48,14 @@ type AuthConfig struct {
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||
FinanceMasterKey string `json:"finance_master_key"`
|
||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||
RequiredAPIKey bool `json:"required_api_key"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" {
|
||||
if path := strings.TrimSpace(os.Getenv("OLONS_SERVER_CONFIG")); path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,34 +82,38 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "workbench:session:token:%s",
|
||||
SessionKeyPattern: "olons_server:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
|
||||
ReadTimeout: 8 * time.Second,
|
||||
WriteTimeout: 8 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
setString(&cfg.Listen, "WORKBENCH_LISTEN")
|
||||
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
|
||||
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
|
||||
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
|
||||
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
|
||||
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
|
||||
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
|
||||
cfg.Auth.APIKeys = splitCSV(keys)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +144,9 @@ func (c *Config) validate() error {
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
|
||||
return errors.New("upload config is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+67
-84
@@ -19,8 +19,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,10 +34,8 @@ const (
|
||||
|
||||
var (
|
||||
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
|
||||
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
|
||||
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
|
||||
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
|
||||
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
@@ -68,14 +66,12 @@ type Service struct {
|
||||
}
|
||||
|
||||
type PaymentAccountInput struct {
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
AccountNo string `json:"account_no"`
|
||||
ReservedPhone string `json:"reserved_phone"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
TradePassword string `json:"trade_password"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
}
|
||||
|
||||
type PaymentAccount struct {
|
||||
@@ -84,9 +80,10 @@ type PaymentAccount struct {
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
MobileMask string `json:"mobile_mask"`
|
||||
IdentityNoMask string `json:"identity_no_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
VerifyStatus string `json:"verify_status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
@@ -235,11 +232,19 @@ type WalletLedgerPage struct {
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
|
||||
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
|
||||
var encryptionKey, lookupKey []byte
|
||||
if len([]byte(masterKey)) >= 32 {
|
||||
encryptionKey = deriveKey(masterKey, "data-encryption")
|
||||
lookupKey = deriveKey(masterKey, "lookup-hmac")
|
||||
} else {
|
||||
var encryptionErr, lookupErr error
|
||||
encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey)
|
||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||
return service
|
||||
}
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return service
|
||||
@@ -254,6 +259,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
||||
return service
|
||||
}
|
||||
|
||||
func deriveKey(masterKey, purpose string) []byte {
|
||||
mac := hmac.New(sha256.New, []byte(masterKey))
|
||||
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
@@ -273,36 +284,29 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
|
||||
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 {
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holderCipher, err := s.encrypt(input.HolderName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
||||
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,
|
||||
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
|
||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
|
||||
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
||||
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
|
||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||
QRAttachmentID: input.QRAttachmentID,
|
||||
WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -349,9 +353,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
reservedPhone := ""
|
||||
if account.MobileCipher != "" {
|
||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
||||
identityNo := ""
|
||||
if account.IdentityNoCipher != "" {
|
||||
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
@@ -359,8 +363,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
snapshot, err := json.Marshal(map[string]any{
|
||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||
"bank_name": account.BankName, "holder_name": holderName,
|
||||
"reserved_phone": reservedPhone, "account_no": accountNo,
|
||||
"qr_attachment_id": account.QRAttachmentID,
|
||||
"identity_no": identityNo, "account_no": accountNo,
|
||||
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -375,8 +380,10 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
|
||||
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
|
||||
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
|
||||
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
|
||||
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
|
||||
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
|
||||
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
|
||||
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
|
||||
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
|
||||
Channel: *channel,
|
||||
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
||||
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
|
||||
@@ -695,7 +702,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||
return nil, ErrTradePassword
|
||||
}
|
||||
key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
||||
key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID)
|
||||
attempts, err := s.redis.Get(ctx, key).Int()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return nil, ErrUnavailable
|
||||
@@ -725,57 +732,26 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
return security, nil
|
||||
}
|
||||
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
|
||||
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
|
||||
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||
input.BankName = strings.TrimSpace(input.BankName)
|
||||
input.HolderName = strings.TrimSpace(input.HolderName)
|
||||
input.AccountNo = strings.TrimSpace(input.AccountNo)
|
||||
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
|
||||
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
switch input.AccountType {
|
||||
case "bank_card":
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
|
||||
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
|
||||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 1, nil
|
||||
case "digital_wallet":
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 2, nil
|
||||
case "unionpay":
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 3, nil
|
||||
case "alipay":
|
||||
input.BankName = "支付宝"
|
||||
input.BankCode = "ALIPAY"
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 4, nil
|
||||
case "wechat":
|
||||
input.BankName = "微信"
|
||||
input.BankCode = "WECHAT"
|
||||
if !accountTokenPattern.MatchString(input.AccountNo) {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
return 5, nil
|
||||
default:
|
||||
return 0, ErrAccountTypeInvalid
|
||||
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
||||
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
||||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
||||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
|
||||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
|
||||
return ErrAccountFieldsInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
|
||||
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
|
||||
AccountNoMask: row.AccountNoMask, WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
@@ -1023,6 +999,13 @@ func maskAccount(value string) string {
|
||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func maskIdentityNo(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return strings.Repeat("*", len(value))
|
||||
}
|
||||
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func validHumanName(value string) bool {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -60,7 +60,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
|
||||
f.createdRecharge = input
|
||||
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
||||
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||
Status: 1, Remark: input.Remark}, nil
|
||||
Status: 2, Remark: input.Remark}, nil
|
||||
}
|
||||
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
|
||||
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
||||
@@ -101,14 +101,14 @@ func TestQuoteFailsClosed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizePaymentAccount(t *testing.T) {
|
||||
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
|
||||
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
if err != nil {
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" {
|
||||
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
|
||||
if result.Status != "reviewing" || result.RequestAmount != 50000 ||
|
||||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
|
||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||
}
|
||||
@@ -219,3 +219,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
|
||||
t.Fatalf("unexpected round trip: %q, %v", plain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
|
||||
service := New(&fakeRepository{}, nil, config.AuthConfig{
|
||||
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
|
||||
})
|
||||
if service.aead == nil || len(service.lookupKey) != 32 {
|
||||
t.Fatal("expected the finance keys to be derived from the master key")
|
||||
}
|
||||
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
|
||||
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
|
||||
t.Fatal("derived keys must be isolated by purpose")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
|
||||
+14
-9
@@ -3,12 +3,13 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
@@ -18,10 +19,11 @@ type API struct {
|
||||
tasks *tasks.Service
|
||||
mall *mall.Service
|
||||
finance *finance.Service
|
||||
attachments *attachments.Service
|
||||
}
|
||||
|
||||
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API {
|
||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
|
||||
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service) *API {
|
||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService}
|
||||
}
|
||||
|
||||
func (a *API) Register(mux *http.ServeMux) {
|
||||
@@ -39,6 +41,8 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
||||
@@ -48,6 +52,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
||||
mux.HandleFunc("/v1/attachments/images", a.uploadImage)
|
||||
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
|
||||
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
|
||||
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
||||
@@ -58,5 +63,5 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
|
||||
writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
)
|
||||
|
||||
func (a *API) uploadImage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
result, err := a.attachments.UploadImage(r.Context(), principal.MemberID, header.Filename, file)
|
||||
if err != nil {
|
||||
if errors.Is(err, attachments.ErrImageInvalid) {
|
||||
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
|
||||
} else {
|
||||
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,10 +3,11 @@ package httpapi
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
@@ -187,6 +188,56 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.portal.PopupNotice(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) != 2 || parts[1] != "read" {
|
||||
writePortalError(w, r, store.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
noticeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || noticeID <= 0 {
|
||||
writePortalError(w, r, store.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodPost)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.portal.MarkNoticeRead(r.Context(), principal.TenantID, principal.MemberID, noticeID); err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, struct{}{})
|
||||
}
|
||||
|
||||
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type avatarRequest struct {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,6 +22,8 @@ type Repository interface {
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||
@@ -203,6 +205,20 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
|
||||
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||
row, err := s.repository.LatestPopupNotice(ctx, tenantID, memberID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt,
|
||||
CreatedAt: row.CreatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||
}
|
||||
|
||||
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -42,6 +42,15 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
if len(r.notices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &r.notices[0], nil
|
||||
}
|
||||
func (r fakeRepository) MarkMemberNoticeRead(context.Context, int64, int64, int64) error {
|
||||
return nil
|
||||
}
|
||||
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
|
||||
if r.noticePage == nil {
|
||||
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
|
||||
@@ -193,3 +202,16 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopupNoticeMapsLatestUnreadNotice(t *testing.T) {
|
||||
service := New(fakeRepository{notices: []store.MemberNotice{{
|
||||
ID: 9, Title: "公告通知", Content: "新活动已上线", SourceType: 1,
|
||||
}}})
|
||||
notice, err := service.PopupNotice(context.Background(), 1, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if notice == nil || notice.ID != 9 || notice.Content != "新活动已上线" || notice.SourceType != "real" {
|
||||
t.Fatalf("unexpected popup notice: %+v", notice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
||||
(tenant_id,member_no,username,mobile,password_hash,
|
||||
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
||||
credit_score,is_test_account,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,1,100,2,0,0)`,
|
||||
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
|
||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
type AttachmentCreation struct {
|
||||
MemberID int64
|
||||
Name string
|
||||
MimeType string
|
||||
Path string
|
||||
URL string
|
||||
Size int64
|
||||
Ext string
|
||||
MD5 string
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID int64 `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
|
||||
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
|
||||
VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
|
||||
input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Attachment{ID: id, URL: input.URL}, nil
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +161,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberNotice
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice
|
||||
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &row, err
|
||||
}
|
||||
|
||||
func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("store is unavailable")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
||||
(tenant_id,notice_id,member_id,read_at,created_at)
|
||||
SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
|
||||
AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
|
||||
|
||||
var avatar Avatar
|
||||
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
|
||||
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
WHERE id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
|
||||
@@ -56,6 +56,8 @@ type RechargePage struct {
|
||||
Total int64
|
||||
}
|
||||
|
||||
const rechargeStatusReview = 2
|
||||
|
||||
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||
items := make([]RechargeChannel, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
|
||||
@@ -95,7 +97,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
for _, attachmentID := range input.VoucherAttachmentIDs {
|
||||
var count int
|
||||
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
|
||||
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||
AND app_id='olons_server' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
@@ -109,8 +111,8 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
|
||||
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
|
||||
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,7 +125,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
|
||||
}
|
||||
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
|
||||
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
|
||||
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
|
||||
VoucherAttachmentIDs: vouchers, Status: rechargeStatusReview, Remark: input.Remark}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
|
||||
|
||||
+264
-44
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -110,6 +111,9 @@ type TaskSubmissionInput struct {
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
LedgerNo string
|
||||
IdempotencyKey string
|
||||
SettledAt time.Time
|
||||
}
|
||||
|
||||
type TaskSubmissionResult struct {
|
||||
@@ -160,8 +164,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
|
||||
var member struct {
|
||||
LevelID int64 `db:"level_id"`
|
||||
AutoDispatch int `db:"auto_dispatch"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member
|
||||
err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member
|
||||
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
@@ -227,32 +232,14 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
return nil, ErrDailyLimitReached
|
||||
}
|
||||
}
|
||||
var task struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
PerMemberLimit int `db:"per_member_limit"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
|
||||
FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=?
|
||||
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL
|
||||
AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3))
|
||||
AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id
|
||||
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
|
||||
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id
|
||||
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit)
|
||||
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
dispatchMode, message := "manual", "等待后台匹配订单"
|
||||
if member.AutoDispatch == 1 {
|
||||
dispatchMode, message = "auto", "正在自动匹配订单"
|
||||
}
|
||||
rules, err := json.Marshal(map[string]any{
|
||||
"commission_amount": task.CommissionAmount,
|
||||
"commission_rate": task.CommissionRate,
|
||||
"source": "member_auto_claim",
|
||||
"dispatch_mode": dispatchMode,
|
||||
"hall_id": input.HallID,
|
||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
"member_level_id": member.LevelID,
|
||||
})
|
||||
@@ -262,10 +249,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
||||
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
|
||||
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
||||
message,order_status,freeze_status,matched_at,created_by,updated_by)
|
||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
|
||||
message,order_status,freeze_status,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
@@ -279,11 +265,148 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
|
||||
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
|
||||
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orderID int64, delaySeconds int, minAmount, maxAmount int64) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var order struct {
|
||||
OrderNo string `db:"order_no"`
|
||||
Status int `db:"order_status"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
|
||||
AutoDispatch int `db:"auto_dispatch"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT o.order_no,o.order_status,o.member_level_id,o.rule_snapshot,o.created_at,m.auto_dispatch
|
||||
FROM hg_trade_order o JOIN hg_app_member m ON m.tenant_id=o.tenant_id AND m.id=o.member_id
|
||||
WHERE o.tenant_id=? AND o.member_id=? AND o.id=? AND o.order_type=2 LIMIT 1 FOR UPDATE`, tenantID, memberID, orderID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrOrderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status != orderStatusMatching {
|
||||
return tx.Commit()
|
||||
}
|
||||
if order.AutoDispatch != 1 || delaySeconds > 0 && time.Now().UTC().Before(order.CreatedAt.UTC().Add(time.Duration(delaySeconds)*time.Second)) {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
var account struct {
|
||||
ID int64 `db:"id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,version FROM hg_wallet_account
|
||||
WHERE tenant_id=? AND member_id=? AND currency='CNY' AND account_status=1 LIMIT 1 FOR UPDATE`, tenantID, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrAccountRestricted
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lowerAmount := max(int64(1), minAmount)
|
||||
upperAmount := account.AvailableAmount
|
||||
if maxAmount > 0 && maxAmount < upperAmount {
|
||||
upperAmount = maxAmount
|
||||
}
|
||||
if lowerAmount > upperAmount {
|
||||
return ErrNoTaskAvailable
|
||||
}
|
||||
var product struct {
|
||||
ID int64 `db:"id"`
|
||||
ProductNo string `db:"product_no"`
|
||||
Name string `db:"name"`
|
||||
PriceAmount int64 `db:"price_amount"`
|
||||
Currency string `db:"currency"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &product, `SELECT id,product_no,name,price_amount,currency FROM hg_product
|
||||
WHERE tenant_id=? AND publish_status=3 AND deleted_at IS NULL AND currency='CNY' AND price_amount>=? AND price_amount<=?
|
||||
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
|
||||
ORDER BY RAND() LIMIT 1 FOR UPDATE`, tenantID, lowerAmount, upperAmount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var levelRule struct {
|
||||
VersionID int64 `db:"version_id"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &levelRule, `SELECT v.id AS version_id,v.commission_rate
|
||||
FROM hg_app_member_level l JOIN hg_app_member_level_version v
|
||||
ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
|
||||
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, tenantID, order.MemberLevelID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrLevelNotEligible
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot := map[string]any{}
|
||||
if len(order.RuleSnapshot) > 0 {
|
||||
_ = json.Unmarshal(order.RuleSnapshot, &snapshot)
|
||||
}
|
||||
snapshot["match_source"] = "system_auto_match"
|
||||
snapshot["product_id"] = product.ID
|
||||
snapshot["product_no"] = product.ProductNo
|
||||
snapshot["unit_price_amount"] = product.PriceAmount
|
||||
snapshot["quantity"] = 1
|
||||
snapshot["level_version_id"] = levelRule.VersionID
|
||||
snapshot["commission_rate"] = levelRule.CommissionRate
|
||||
rules, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000
|
||||
afterAvailable := account.AvailableAmount - product.PriceAmount
|
||||
afterFrozen := account.FrozenAmount + product.PriceAmount
|
||||
walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
|
||||
updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`,
|
||||
afterAvailable, afterFrozen, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID),
|
||||
"task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, afterAvailable,
|
||||
"match:available", "自动派单扣除可用余额"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID),
|
||||
"task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, afterFrozen,
|
||||
"match:frozen", "自动派单冻结订单本金"); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1,
|
||||
unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?,
|
||||
order_status=2,freeze_status=1,matched_at=UTC_TIMESTAMP(3),updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name,
|
||||
product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules,
|
||||
"系统已自动匹配订单,订单本金已扣除,请在截止时间前完成", tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrConcurrentUpdate
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||
@@ -364,13 +487,15 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var order struct {
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
|
||||
err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
|
||||
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.OrderID)
|
||||
@@ -383,8 +508,48 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
var account struct {
|
||||
ID int64 `db:"id"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
FrozenAmount int64 `db:"frozen_amount"`
|
||||
Status int `db:"account_status"`
|
||||
Version int `db:"version"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,account_status,version FROM hg_wallet_account
|
||||
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account.Status != 1 || account.FrozenAmount < order.TotalAmount {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if order.DeadlineAt != "" {
|
||||
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
|
||||
now := time.Now().UTC()
|
||||
afterAvailable := account.AvailableAmount + order.TotalAmount
|
||||
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
|
||||
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAvailable, afterFrozen,
|
||||
now, input.TenantID, account.ID, account.Version)
|
||||
if updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if affected, _ := walletResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dEF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||
order.TotalAmount, account.FrozenAmount, afterFrozen, "expire:frozen", "订单超时释放冻结本金"); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dEA", input.OrderID), "task_principal_release", order.OrderNo, "available", 1,
|
||||
order.TotalAmount, account.AvailableAmount, afterAvailable, "expire:available", "订单超时退回订单本金"); updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
||||
return nil, updateErr
|
||||
@@ -398,24 +563,64 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.SubmissionNo == "" {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
attachments, err := json.Marshal(input.AttachmentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
now := input.SettledAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
principalAfter := account.AvailableAmount + order.TotalAmount
|
||||
afterAmount := principalAfter + order.CommissionAmount
|
||||
afterFrozen := account.FrozenAmount - order.TotalAmount
|
||||
accountResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,
|
||||
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAmount, afterFrozen,
|
||||
order.CommissionAmount, now, input.TenantID, account.ID, account.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := accountResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
|
||||
order.TotalAmount, account.FrozenAmount, afterFrozen, "settle:frozen", "任务完成释放冻结本金"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSA", input.OrderID), "task_principal_return", order.OrderNo, "available", 1,
|
||||
order.TotalAmount, account.AvailableAmount, principalAfter, "settle:available", "任务完成返回订单本金"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledgerID, err := insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
|
||||
fmt.Sprintf("WL%dSC", input.OrderID), "commission", input.SubmissionNo, "available", 1,
|
||||
order.CommissionAmount, principalAfter, afterAmount, "settle:commission", "任务完成自动返佣")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submissionResult, err := tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
|
||||
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
|
||||
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,4,?,10000,0,?,'AUTO_SETTLED','会员提交后自动结算',2,?,?,0,0,?,?)`,
|
||||
input.TenantID, input.SubmissionNo, input.OrderID, order.TaskID, input.MemberID, order.MemberLevelID,
|
||||
input.EvidenceText, attachments, order.CommissionAmount, now, ledgerID, now, now, now)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
|
||||
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=4,message='订单完成,佣金已入账',
|
||||
completed_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, now, now, input.TenantID, input.OrderID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +630,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
|
||||
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusCompleted, SubmittedAt: now}, nil
|
||||
}
|
||||
|
||||
func insertTaskLedger(ctx context.Context, tx *sqlx.Tx, tenantID, memberID, accountID, orderID int64,
|
||||
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64,
|
||||
idempotencySuffix, remark string) (int64, error) {
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger
|
||||
(tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount,
|
||||
before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY',0,?,?,?,UTC_TIMESTAMP(3))`, tenantID, ledgerNo, accountID, memberID,
|
||||
bizType, bizNo, entryType, direction, amount, beforeAmount, afterAmount,
|
||||
fmt.Sprintf("task:%d:%s", orderID, idempotencySuffix), remark, memberID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
|
||||
@@ -433,7 +653,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
|
||||
return nil
|
||||
}
|
||||
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
|
||||
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1
|
||||
AND tenant_id=? AND app_id='olons_server' AND member_id=? AND status=1
|
||||
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -16,12 +16,17 @@ type PaymentAccount struct {
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
IdentityNoCipher string `db:"identity_no_cipher"`
|
||||
IdentityNoHash string `db:"identity_no_hash"`
|
||||
IdentityNoMask string `db:"identity_no_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
|
||||
VerifyStatus int `db:"verify_status"`
|
||||
IsDefault int `db:"is_default"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
@@ -31,18 +36,19 @@ type PaymentAccount struct {
|
||||
type PaymentAccountCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
MobileCipher string
|
||||
MobileMask string
|
||||
IdentityNoCipher string
|
||||
IdentityNoHash string
|
||||
IdentityNoMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
QRAttachmentID int64
|
||||
WechatQRAttachmentID int64
|
||||
AlipayQRAttachmentID int64
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
@@ -76,10 +82,12 @@ type WithdrawalCreation struct {
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedMobileCipher string
|
||||
ExpectedIdentityNoCipher string
|
||||
ExpectedIdentityNoHash string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedQRAttachmentID int64
|
||||
ExpectedWechatQRAttachmentID int64
|
||||
ExpectedAlipayQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
@@ -126,8 +134,10 @@ type WithdrawalPage struct {
|
||||
|
||||
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
items := make([]PaymentAccount, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
|
||||
@@ -143,26 +153,23 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
defer tx.Rollback()
|
||||
var memberID int64
|
||||
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
|
||||
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
|
||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
||||
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.QRAttachmentID > 0 {
|
||||
var attachmentCount int
|
||||
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||
input.TenantID, input.QRAttachmentID, input.MemberID)
|
||||
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attachmentCount != 1 {
|
||||
if attachmentCount != 2 {
|
||||
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 {
|
||||
@@ -173,12 +180,15 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
isDefault = 1
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
|
||||
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher,
|
||||
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
|
||||
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status,
|
||||
is_default,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
|
||||
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask,
|
||||
input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask,
|
||||
input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
|
||||
input.WechatQRAttachmentID, input.AlipayQRAttachmentID,
|
||||
isDefault, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
@@ -194,9 +204,10 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
return nil, err
|
||||
}
|
||||
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
|
||||
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
|
||||
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
|
||||
VerifyStatus: 1, IsDefault: isDefault}, nil
|
||||
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
|
||||
AccountNoMask: input.AccountNoMask, WechatQRAttachmentID: input.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
|
||||
VerifyStatus: 2, IsDefault: isDefault}, nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
@@ -209,8 +220,10 @@ func nullableString(value string) any {
|
||||
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
|
||||
var row PaymentAccount
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
|
||||
@@ -289,8 +302,11 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
|
||||
var account PaymentAccount
|
||||
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
|
||||
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
|
||||
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
|
||||
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
|
||||
verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
|
||||
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -301,8 +317,10 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
}
|
||||
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
||||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
||||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
|
||||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
|
||||
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
|
||||
account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
|
||||
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
|
||||
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,6 +27,7 @@ type Repository interface {
|
||||
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
||||
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
|
||||
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
|
||||
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error
|
||||
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
|
||||
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
||||
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
||||
@@ -150,6 +152,9 @@ type SubmitResult struct {
|
||||
type taskPolicy struct {
|
||||
Enabled bool
|
||||
MinimumAmount int64
|
||||
AutoDelay int
|
||||
AutoMinAmount int64
|
||||
AutoMaxAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
@@ -274,6 +279,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.OrderStatus == 1 {
|
||||
policy, policyErr := s.policy(ctx, tenantID)
|
||||
if policyErr != nil {
|
||||
return nil, policyErr
|
||||
}
|
||||
if policy.Enabled {
|
||||
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
|
||||
return nil, err
|
||||
}
|
||||
if policy.Enabled && err == nil {
|
||||
row, err = s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -300,17 +323,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
|
||||
}
|
||||
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
||||
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
|
||||
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) {
|
||||
!validUniqueIDs(input.EvidenceAttachmentIDs) {
|
||||
return nil, ErrEvidenceInvalid
|
||||
}
|
||||
now := s.now().UTC()
|
||||
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
||||
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
|
||||
AttachmentIDs: input.EvidenceAttachmentIDs})
|
||||
AttachmentIDs: input.EvidenceAttachmentIDs, LedgerNo: newNumber("WL", now),
|
||||
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
|
||||
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
|
||||
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
|
||||
}
|
||||
|
||||
@@ -331,6 +355,9 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
|
||||
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
||||
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
||||
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
||||
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
|
||||
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
|
||||
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
@@ -29,9 +30,20 @@ func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (
|
||||
}
|
||||
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
||||
f.claimed = input
|
||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY",
|
||||
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
|
||||
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
|
||||
}
|
||||
func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error {
|
||||
if f.order != nil && f.order.OrderStatus == 1 {
|
||||
f.order.OrderStatus = 2
|
||||
f.order.ProductID = 7
|
||||
f.order.ProductName = "自动匹配商品"
|
||||
f.order.Quantity = 1
|
||||
f.order.TotalAmount = 10000
|
||||
f.order.CommissionAmount = 100
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
|
||||
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
|
||||
}
|
||||
@@ -43,7 +55,7 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
|
||||
}
|
||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||
f.submitted = input
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
|
||||
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4,
|
||||
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
|
||||
}
|
||||
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
|
||||
@@ -75,7 +87,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
func TestClaimBuildsMatchingOrder(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
||||
service := New(repository)
|
||||
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||
@@ -85,29 +97,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "pending" || repository.claimed.HallID != 2 ||
|
||||
if result.Status != "matching" || repository.claimed.HallID != 2 ||
|
||||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
|
||||
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) {
|
||||
t.Fatalf("expected evidence error, got %v", err)
|
||||
}
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
|
||||
if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" {
|
||||
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitStillValidatesOptionalEvidence(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
tooLong := strings.Repeat("字", 2001)
|
||||
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) {
|
||||
t.Fatalf("expected evidence error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
service := New(&fakeRepository{})
|
||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'olons_web_session';
|
||||
const DEVICE_KEY = 'olons_web_device';
|
||||
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
AUTH_REQUIRED: '登录已失效,请重新登录',
|
||||
@@ -19,6 +20,9 @@ const reasonMessages: Record<string, string> = {
|
||||
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
||||
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
||||
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
||||
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
|
||||
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
|
||||
IMAGE_INVALID: '图片格式或大小不符合要求',
|
||||
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
||||
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
||||
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
||||
@@ -132,16 +136,26 @@ function getDeviceID() {
|
||||
|
||||
export function saveSession(token: string) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
|
||||
}
|
||||
|
||||
export function hasSession() {
|
||||
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
||||
}
|
||||
|
||||
export function shouldShowLoginNotice() {
|
||||
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
|
||||
}
|
||||
|
||||
export function completeLoginNotice() {
|
||||
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
|
||||
}
|
||||
|
||||
export function idempotencyKey() {
|
||||
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="announcement-fade">
|
||||
<div v-if="show" class="m-announcement-mask" role="presentation">
|
||||
<section
|
||||
class="m-announcement-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
aria-describedby="announcement-content"
|
||||
>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">
|
||||
{{ notice?.title || '公告通知' }}
|
||||
</h2>
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
{{ notice?.content }}
|
||||
</div>
|
||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import announcementBoard from '../assets/announcement-popup@2x.png';
|
||||
|
||||
export interface AnnouncementNotice {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
notice?: AnnouncementNotice | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{ read: [] }>();
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
document.body.classList.toggle('m-announcement-open', show);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-announcement-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px 16px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
.m-announcement-dialog {
|
||||
position: relative;
|
||||
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.m-announcement-board {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.m-announcement-body {
|
||||
position: absolute;
|
||||
inset: 31.2% 16.7% 13.6%;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m-announcement-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
color: #282828;
|
||||
font-size: clamp(14px, 4.25vw, 16px);
|
||||
line-height: 1.42;
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m-announcement-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m-announcement-body button {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
height: 41px;
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #ff972c 0%, #f45119 100%);
|
||||
box-shadow: 0 5px 12px rgba(244, 81, 25, 0.2);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-announcement-sr-title {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-active,
|
||||
.announcement-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-from,
|
||||
.announcement-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.m-announcement-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1008,6 +1008,91 @@ body {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-order-submit-panel {
|
||||
padding: 17px;
|
||||
border-radius: 8px;
|
||||
background: #fff8ea;
|
||||
box-shadow: 0 4px 18px rgba(73, 54, 31, 0.07);
|
||||
}
|
||||
.m-order-submit-panel strong {
|
||||
color: #6f3f12;
|
||||
font-size: 17px;
|
||||
}
|
||||
.m-order-submit-panel p {
|
||||
margin: 7px 0 15px;
|
||||
color: #8b765e;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.m-order-submit-panel > button,
|
||||
.m-order-submit-actions button:last-child {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #ffa32b, #f15b20);
|
||||
}
|
||||
.m-order-submit-panel > button {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.m-order-submit-panel button:disabled,
|
||||
.m-order-submit-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.m-order-submit-confirm {
|
||||
width: min(90vw, 430px);
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 50px rgba(70, 40, 10, 0.2);
|
||||
}
|
||||
.m-order-submit-confirm h2 {
|
||||
margin: 0;
|
||||
padding: 24px 22px 8px;
|
||||
text-align: center;
|
||||
font-size: 21px;
|
||||
}
|
||||
.m-order-submit-confirm > p {
|
||||
margin: 0;
|
||||
padding: 0 25px 18px;
|
||||
color: #8a8a8a;
|
||||
text-align: center;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.m-order-submit-confirm dl {
|
||||
margin: 0 20px 22px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 10px;
|
||||
background: #faf8f5;
|
||||
}
|
||||
.m-order-submit-confirm dl > div {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
.m-order-submit-confirm dt {
|
||||
flex: 0 0 auto;
|
||||
color: #777;
|
||||
}
|
||||
.m-order-submit-confirm dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
.m-order-submit-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid #f0ebe4;
|
||||
}
|
||||
.m-order-submit-actions button {
|
||||
min-height: 54px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.m-btn-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1449,9 +1534,70 @@ body {
|
||||
color: var(--m-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.m-form-hint a {
|
||||
color: var(--m-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-plain-form > .m-form-error {
|
||||
margin: 15px 30px;
|
||||
}
|
||||
.m-bank-form {
|
||||
margin-top: 18px;
|
||||
padding-bottom: 38px;
|
||||
}
|
||||
.m-bank-form > label {
|
||||
min-height: 66px;
|
||||
grid-template-columns: 125px 1fr;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.m-bank-form__divider {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
.m-qr-field {
|
||||
padding: 16px 15px 0;
|
||||
}
|
||||
.m-qr-field h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-qr-upload {
|
||||
width: 100%;
|
||||
aspect-ratio: 2.12 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px dashed #d1d1d1;
|
||||
border-radius: 5px;
|
||||
background: #f8f8f8;
|
||||
color: #b8b8b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-qr-upload input {
|
||||
display: none;
|
||||
}
|
||||
.m-qr-upload > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.m-qr-upload .n-icon {
|
||||
font-size: 50px;
|
||||
color: #ddd;
|
||||
}
|
||||
.m-qr-upload b {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-qr-upload img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-verify label:nth-child(2) {
|
||||
grid-template-columns: 125px 1fr auto;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
|
||||
<button
|
||||
type="button"
|
||||
:disabled="claiming || !halls.length || eligibility?.eligible === false"
|
||||
:disabled="claiming || !eligibleHalls.length"
|
||||
aria-label="自动领取任务"
|
||||
@click="claim"
|
||||
>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
|
||||
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning">
|
||||
<n-icon :component="AlertCircleOutline" />
|
||||
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
|
||||
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
|
||||
@@ -100,7 +100,7 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
||||
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
|
||||
@@ -112,6 +112,7 @@
|
||||
const halls = ref<any[]>([]);
|
||||
const stats = ref<any>();
|
||||
const eligibility = ref<any>();
|
||||
const eligibilityByHall = ref<Record<number, any>>({});
|
||||
const order = ref<any>();
|
||||
const showResult = ref(false);
|
||||
const resultState = ref<ResultState>('matching');
|
||||
@@ -128,6 +129,9 @@
|
||||
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
|
||||
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
|
||||
]);
|
||||
const eligibleHalls = computed(() =>
|
||||
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
|
||||
);
|
||||
const businessHours = computed(() => {
|
||||
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
|
||||
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
|
||||
@@ -154,7 +158,7 @@
|
||||
]);
|
||||
halls.value = hallData || [];
|
||||
stats.value = statData;
|
||||
if (halls.value.length) await loadEligibility(halls.value[0].id);
|
||||
if (halls.value.length) await loadEligibilities();
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
@@ -162,8 +166,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEligibility(hallID: number) {
|
||||
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
|
||||
async function loadEligibilities() {
|
||||
const entries = await Promise.all(
|
||||
halls.value.map(async (hall) => [
|
||||
hall.id,
|
||||
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
|
||||
] as const)
|
||||
);
|
||||
eligibilityByHall.value = Object.fromEntries(entries);
|
||||
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
|
||||
}
|
||||
|
||||
async function claim() {
|
||||
@@ -171,18 +182,33 @@
|
||||
claiming.value = true;
|
||||
pollFinished.value = false;
|
||||
try {
|
||||
await loadEligibility(halls.value[0].id);
|
||||
if (!eligibility.value?.eligible) {
|
||||
await loadEligibilities();
|
||||
const candidates = eligibleHalls.value;
|
||||
if (!candidates.length) {
|
||||
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
|
||||
return;
|
||||
}
|
||||
openMatching();
|
||||
let stockError: unknown;
|
||||
for (const hall of candidates) {
|
||||
eligibility.value = eligibilityByHall.value[hall.id];
|
||||
try {
|
||||
order.value = await request<any>({
|
||||
url: '/v1/task-orders/claim',
|
||||
method: 'POST',
|
||||
data: { hall_id: halls.value[0].id },
|
||||
data: { hall_id: hall.id },
|
||||
});
|
||||
handleOrder(order.value);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
|
||||
stockError = err;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw stockError || new Error('当前暂无可领取任务');
|
||||
} catch (err) {
|
||||
failureMessage.value = errorText(err);
|
||||
resultState.value = 'failed';
|
||||
|
||||
@@ -43,38 +43,38 @@
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
|
||||
<form v-else-if="formType === 'bank'" class="m-plain-form m-bank-form" @submit.prevent="saveBank">
|
||||
<label
|
||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||
/></label>
|
||||
<label
|
||||
><span>身份证号</span
|
||||
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
|
||||
/></label>
|
||||
<div class="m-bank-form__divider"></div>
|
||||
<label
|
||||
><span>银行卡号</span
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
||||
/></label>
|
||||
<label
|
||||
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
|
||||
/></label>
|
||||
<label
|
||||
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
|
||||
/></label>
|
||||
<label
|
||||
><span>预留手机号</span
|
||||
><input
|
||||
v-model.trim="bank.reserved_phone"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入预留手机号(选填)"
|
||||
/></label>
|
||||
<label
|
||||
><span>交易密码</span
|
||||
><input
|
||||
v-model="bank.trade_password"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位交易密码"
|
||||
/></label>
|
||||
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
|
||||
<section class="m-qr-field">
|
||||
<h3>微信二维码</h3>
|
||||
<label class="m-qr-upload">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
|
||||
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
|
||||
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : '点击上传' }}</b></span>
|
||||
</label>
|
||||
</section>
|
||||
<section class="m-qr-field">
|
||||
<h3>支付宝二维码</h3>
|
||||
<label class="m-qr-upload">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('alipay', $event)" />
|
||||
<img v-if="qr.alipay.url" :src="safeImage(qr.alipay.url)" alt="支付宝二维码" />
|
||||
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.alipay.uploading ? '上传中...' : '点击上传' }}</b></span>
|
||||
</label>
|
||||
</section>
|
||||
<p v-if="error" class="m-form-error">{{ error }}</p
|
||||
><button class="m-primary-btn" :disabled="submitting">{{
|
||||
submitting ? '提交中...' : '确认绑定'
|
||||
@@ -208,6 +208,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
CameraOutline,
|
||||
ChevronForwardOutline,
|
||||
KeyOutline,
|
||||
LockClosedOutline,
|
||||
@@ -239,14 +240,16 @@
|
||||
const content = ref<any>();
|
||||
const resetPassword = ref('');
|
||||
const bank = reactive({
|
||||
account_type: 'bank_card',
|
||||
bank_code: '',
|
||||
bank_name: '',
|
||||
holder_name: '',
|
||||
identity_no: '',
|
||||
account_no: '',
|
||||
reserved_phone: '',
|
||||
trade_password: '',
|
||||
qr_attachment_id: 0,
|
||||
wechat_qr_attachment_id: 0,
|
||||
alipay_qr_attachment_id: 0,
|
||||
});
|
||||
const qr = reactive({
|
||||
wechat: { uploading: false, url: '' },
|
||||
alipay: { uploading: false, url: '' },
|
||||
});
|
||||
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
|
||||
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
|
||||
@@ -266,24 +269,55 @@
|
||||
error.value = '';
|
||||
if (
|
||||
!bank.holder_name ||
|
||||
!/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(bank.identity_no.replace(/[ -]/g, '')) ||
|
||||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
|
||||
!bank.bank_name ||
|
||||
!/^[0-9]{6}$/.test(bank.trade_password)
|
||||
!bank.wechat_qr_attachment_id ||
|
||||
!bank.alipay_qr_attachment_id
|
||||
) {
|
||||
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
|
||||
error.value = '请完整填写绑卡信息并上传微信、支付宝二维码';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
|
||||
window.$message?.success('提交成功,等待后台核验');
|
||||
router.back();
|
||||
window.$message?.success('银行卡绑定成功');
|
||||
await router.replace('/app/profile/withdrawal');
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function uploadQRCode(kind: 'wechat' | 'alipay', event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
|
||||
error.value = '二维码仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
|
||||
return;
|
||||
}
|
||||
const target = qr[kind];
|
||||
target.uploading = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const uploaded = await request<{ id: number; url: string }>({
|
||||
url: '/v1/attachments/images',
|
||||
method: 'POST',
|
||||
data: form,
|
||||
});
|
||||
target.url = uploaded.url;
|
||||
if (kind === 'wechat') bank.wechat_qr_attachment_id = uploaded.id;
|
||||
else bank.alipay_qr_attachment_id = uploaded.id;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
target.uploading = false;
|
||||
}
|
||||
}
|
||||
async function saveTradePassword() {
|
||||
error.value = '';
|
||||
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<section class="m-home">
|
||||
<AnnouncementPopup
|
||||
:show="Boolean(popupNotice)"
|
||||
:notice="popupNotice"
|
||||
@read="markPopupRead"
|
||||
/>
|
||||
<div class="m-home-hero" :style="bannerStyle">
|
||||
<label class="m-search">
|
||||
<n-icon :component="SearchOutline" />
|
||||
@@ -81,7 +86,17 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, money, request, safeImage } from '../api';
|
||||
import AnnouncementPopup, {
|
||||
type AnnouncementNotice,
|
||||
} from '../components/AnnouncementPopup.vue';
|
||||
import {
|
||||
completeLoginNotice,
|
||||
errorText,
|
||||
money,
|
||||
request,
|
||||
safeImage,
|
||||
shouldShowLoginNotice,
|
||||
} from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
interface HomeData {
|
||||
@@ -108,6 +123,7 @@
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
const bannerStyle = computed(() => {
|
||||
const url = safeImage(home.value?.banners?.[0]?.image_url);
|
||||
@@ -166,17 +182,30 @@
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [homeData, rankData] = await Promise.all([
|
||||
const noticeRequest = shouldShowLoginNotice()
|
||||
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
const [homeData, rankData, noticeData] = await Promise.all([
|
||||
request<HomeData>({ url: '/v1/home' }),
|
||||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||||
noticeRequest,
|
||||
]);
|
||||
home.value = homeData;
|
||||
leaderboard.value = rankData || [];
|
||||
popupNotice.value = noticeData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markPopupRead() {
|
||||
if (!popupNotice.value) return;
|
||||
const noticeId = popupNotice.value.id;
|
||||
popupNotice.value = null;
|
||||
completeLoginNotice();
|
||||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
||||
}
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -103,8 +103,31 @@
|
||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||
>
|
||||
</section>
|
||||
<section v-if="canSubmit" class="m-order-submit-panel">
|
||||
<div>
|
||||
<strong>任务已匹配成功</strong>
|
||||
<p>请确认任务信息,提交后佣金将自动结算到可用余额。</p>
|
||||
</div>
|
||||
<button type="button" :disabled="submitting" @click="showSubmitConfirm = true">立即提交</button>
|
||||
</section>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
|
||||
<section class="m-order-submit-confirm">
|
||||
<h2>确认提交任务</h2>
|
||||
<p>提交后系统将自动完成订单并发放佣金,请确认订单信息无误。</p>
|
||||
<dl>
|
||||
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
|
||||
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
|
||||
<div><dt>预计佣金</dt><dd>{{ money(detail?.commission_amount, detail?.currency) }}</dd></div>
|
||||
</dl>
|
||||
<div class="m-order-submit-actions">
|
||||
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
|
||||
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '立即提交' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -114,7 +137,7 @@
|
||||
import { ImageOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
||||
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
|
||||
|
||||
const route = useRoute();
|
||||
const isDetail = computed(() => Boolean(route.params.id));
|
||||
@@ -124,6 +147,8 @@
|
||||
const orders = ref<any[]>([]);
|
||||
const detail = ref<any>();
|
||||
const stats = ref<any>();
|
||||
const showSubmitConfirm = ref(false);
|
||||
const submitting = ref(false);
|
||||
let listController: AbortController | undefined;
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@@ -149,6 +174,9 @@
|
||||
failed: '失败',
|
||||
};
|
||||
const statusText = (value: string) => labels[value] || value;
|
||||
const canSubmit = computed(
|
||||
() => detail.value?.status === 'pending' && detail.value?.allowed_actions?.includes('submit')
|
||||
);
|
||||
async function loadList() {
|
||||
listController?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -185,11 +213,34 @@
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function submitOrder() {
|
||||
if (!detail.value?.id || submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: `/v1/task-orders/${detail.value.id}/submissions`,
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
showSubmitConfirm.value = false;
|
||||
window.$message.success('提交成功,佣金已自动入账');
|
||||
await loadDetail();
|
||||
} catch (err) {
|
||||
window.$message.error(errorText(err));
|
||||
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
|
||||
showSubmitConfirm.value = false;
|
||||
await loadDetail();
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
function changeTab(value: string) {
|
||||
status.value = value;
|
||||
loadList();
|
||||
}
|
||||
function load() {
|
||||
showSubmitConfirm.value = false;
|
||||
if (isDetail.value) loadDetail();
|
||||
else loadList();
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
remark: `姓名:${form.name}`,
|
||||
},
|
||||
});
|
||||
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
|
||||
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
|
||||
form.amount = '';
|
||||
form.name = '';
|
||||
} catch (err) {
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
<span>加载中</span>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showConfirm">
|
||||
<n-modal
|
||||
v-model:show="showConfirm"
|
||||
:auto-focus="false"
|
||||
:trap-focus="false"
|
||||
@after-enter="focusTradePassword"
|
||||
>
|
||||
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
||||
<h2>确认取出</h2>
|
||||
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
||||
@@ -50,8 +55,11 @@
|
||||
<label>
|
||||
<span>支付密码</span>
|
||||
<input
|
||||
ref="tradePasswordInput"
|
||||
v-model="form.tradePassword"
|
||||
type="password"
|
||||
name="trade-password"
|
||||
autocomplete="off"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位支付密码"
|
||||
@@ -65,7 +73,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ChevronForwardOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
@@ -83,6 +91,7 @@
|
||||
const quote = ref<any>();
|
||||
const showConfirm = ref(false);
|
||||
const withdrawalKey = ref('');
|
||||
const tradePasswordInput = ref<HTMLInputElement>();
|
||||
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((item) => item.id === form.paymentAccountId)
|
||||
@@ -101,6 +110,10 @@
|
||||
function openAccountPicker() {
|
||||
if (!selectedAccount.value) router.push('/app/profile/bank-card');
|
||||
}
|
||||
async function focusTradePassword() {
|
||||
await nextTick();
|
||||
tradePasswordInput.value?.focus();
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = '';
|
||||
@@ -138,6 +151,7 @@
|
||||
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
|
||||
});
|
||||
withdrawalKey.value = idempotencyKey();
|
||||
form.tradePassword = '';
|
||||
showConfirm.value = true;
|
||||
} catch (err) {
|
||||
actionError.value = errorText(err);
|
||||
|
||||
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user