feat: 完善移动端会员业务流程

This commit is contained in:
richjohnrambo
2026-10-13 14:19:55 +08:00
parent 32c6238751
commit b2ebea4c80
59 changed files with 1516 additions and 470 deletions
+2
View File
@@ -26,6 +26,8 @@ go.work.sum
# env file # env file
.env .env
.idea/ .idea/
.DS_Store
storage/uploads/
# ---> Node # ---> Node
# Logs # Logs
+5 -5
View File
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist ./web/dist COPY --from=web-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
FROM alpine:3.22 FROM alpine:3.22
WORKDIR /app WORKDIR /app
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
COPY --from=builder /out/im_workbench /app/im_workbench COPY --from=builder /out/olons_server /app/olons_server
COPY configs /app/configs COPY configs /app/configs
COPY --from=web-builder /web/dist /app/web/dist COPY --from=web-builder /web/dist /app/web/dist
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
EXPOSE 6061 EXPOSE 6061
ENTRYPOINT ["/app/im_workbench"] ENTRYPOINT ["/app/olons_server"]
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: build test run web-install web-build .PHONY: build test run web-install web-build
build: build:
go build -o bin/im_workbench ./cmd/workbench go build -o bin/olons_server ./cmd/olons_server
test: test:
go test ./... go test ./...
run: run:
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
web-install: web-install:
cd web && pnpm install --frozen-lockfile cd web && pnpm install --frozen-lockfile
+11 -12
View File
@@ -1,8 +1,8 @@
# Workbench # Olons Server
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。 移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。 项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
## 当前接口 ## 当前接口
@@ -42,17 +42,16 @@
核心环境变量: 核心环境变量:
```bash ```bash
WORKBENCH_CONFIG=configs/workbench.local.json OLONS_SERVER_CONFIG=configs/olons_server.local.json
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local' OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
WORKBENCH_REDIS_ADDR='127.0.0.1:6379' OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>' OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>' OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
``` ```
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。 `finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端: 前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
```bash ```bash
make web-install make web-install
@@ -60,7 +59,7 @@ make web-build
make run make run
``` ```
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。 生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
## 请求头 ## 请求头
@@ -70,7 +69,7 @@ X-Request-ID: <request id>
X-Device-ID: <optional device id> X-Device-ID: <optional device id>
``` ```
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
## 验证 ## 验证
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
@@ -59,9 +59,9 @@ func main() {
log.Fatal("mobile_cipher is missing before migration") log.Fatal("mobile_cipher is missing before migration")
} }
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey)) aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey)) lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES")) overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -10,8 +10,8 @@ import (
"syscall" "syscall"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/app" "git.118im.com/im_team/olons_server/internal/app"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
) )
func main() { func main() {
@@ -34,7 +34,7 @@ func main() {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
log.Printf("workbench listening on %s", cfg.Listen) log.Printf("olons_server listening on %s", cfg.Listen)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err errCh <- err
} }
@@ -10,13 +10,16 @@
"addr": "127.0.0.1:6379", "addr": "127.0.0.1:6379",
"db": 0 "db": 0
}, },
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": { "auth": {
"api_keys": [], "api_keys": [],
"session_key_pattern": "workbench:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0, "default_tenant_id": 0,
"data_encryption_key": "", "finance_master_key": "olons-finance-master-key-v1-20261013",
"lookup_hmac_key": "",
"allow_dev_header_auth": true, "allow_dev_header_auth": true,
"required_api_key": false "required_api_key": false
} }
@@ -10,13 +10,16 @@
"addr": "", "addr": "",
"db": 0 "db": 0
}, },
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": { "auth": {
"api_keys": [], "api_keys": [],
"session_key_pattern": "workbench:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0, "default_tenant_id": 0,
"data_encryption_key": "", "finance_master_key": "olons-finance-master-key-v1-20261013",
"lookup_hmac_key": "",
"required_api_key": true "required_api_key": true
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

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