diff --git a/docs/api.yaml b/docs/api.yaml index 40e75f8..8973d6c 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -17,81 +17,83 @@ servers: description: relative server root tags: - - name: public/auth + - name: 公共端/认证 description: 公开认证接口 - - name: public/resource + - name: 公共端/套餐 description: 公开套餐接口 - - name: public/trade + - name: 公共端/交易 description: 公开交易接口 - - name: public/inquiry + - name: 公共端/咨询 description: 公开咨询接口 - - name: public/product + - name: 公共端/产品 description: 公开产品接口 - - name: client/verify - description: 客户端验证接口 - - name: client/proxy - description: 客户端代理接口 - - name: client/channel - description: 客户端通道接口 - - name: client/article - description: 客户端文章接口 - - name: user/user + - name: 机器端/验证 + description: 机器端验证接口 + - name: 机器端/代理 + description: 机器端代理接口 + - name: 机器端/通道 + description: 机器端通道接口 + - name: 机器端/文章 + description: 机器端文章接口 + - name: 用户端/用户资料 description: 用户资料接口 - - name: user/whitelist + - name: 用户端/白名单 description: 用户白名单接口 - - name: user/resource + - name: 用户端/套餐 description: 用户套餐接口 - - name: user/batch + - name: 用户端/批次 description: 用户批次接口 - - name: user/channel + - name: 用户端/通道 description: 用户通道接口 - - name: user/trade + - name: 用户端/交易 description: 用户交易接口 - - name: user/bill + - name: 用户端/账单 description: 用户账单接口 - - name: user/balance + - name: 用户端/余额变动 description: 用户余额变动接口 - - name: user/coupon-user + - name: 用户端/已发放优惠券 description: 用户已发放优惠券接口 - - name: user/announcement + - name: 用户端/公告 description: 用户公告接口 - - name: user/verify + - name: 用户端/验证 description: 用户验证接口 - - name: admin/admin + - name: 管理端/管理员 description: 管理员管理接口 - - name: admin/admin-role + - name: 管理端/首页 + description: 后台首页统计接口 + - name: 管理端/管理员角色 description: 管理员角色接口 - - name: admin/permission + - name: 管理端/权限 description: 权限接口 - - name: admin/user + - name: 管理端/用户 description: 后台用户接口 - - name: admin/resource + - name: 管理端/套餐 description: 后台套餐接口 - - name: admin/batch + - name: 管理端/批次 description: 后台批次接口 - - name: admin/channel + - name: 管理端/通道 description: 后台通道接口 - - name: admin/proxy + - name: 管理端/代理 description: 后台代理接口 - - name: admin/trade + - name: 管理端/交易 description: 后台交易接口 - - name: admin/bill + - name: 管理端/账单 description: 后台账单接口 - - name: admin/balance-activity + - name: 管理端/余额变动 description: 后台余额变动接口 - - name: admin/product + - name: 管理端/产品 description: 后台产品接口 - - name: admin/discount + - name: 管理端/折扣 description: 后台折扣接口 - - name: admin/coupon + - name: 管理端/优惠券 description: 后台优惠券接口 - - name: admin/coupon-user + - name: 管理端/已发放优惠券 description: 后台已发放优惠券接口 - - name: admin/article + - name: 管理端/文章 description: 后台文章接口 - - name: admin/article-group + - name: 管理端/文章分组 description: 后台文章分组接口 - - name: callback/identify + - name: 回调/实名认证 description: 实名认证回调接口 components: @@ -1953,7 +1955,7 @@ components: GetArticleRequest: type: object - description: `id` 与 `code` 必须且只能提供一个。按 `id` 查询用于兼容旧文章链接,按 `code` 查询用于固定引用链接。 + description: "`id` 与 `code` 必须且只能提供一个。按 `id` 查询用于兼容旧文章链接,按 `code` 查询用于固定引用链接" properties: id: type: integer @@ -3539,10 +3541,119 @@ components: required: - list + HomeOverviewResponse: + type: object + description: 全平台后台首页概览数据,今日指标均按北京时间自然日统计。 + properties: + pending_customer_count: + type: integer + format: int64 + minimum: 0 + description: 待分配客户数量,即尚未绑定管理员的客户数。 + active_channel_count: + type: integer + format: int64 + minimum: 0 + description: 当前有效通道数量,即过期时间晚于当前时间的通道数。 + today_trade_amount: + type: string + description: 今日交易金额,统计今日完成的成功订单,不扣除退款。 + example: "1288.00" + today_extract_count: + type: integer + format: int64 + minimum: 0 + description: 今日提取总量。 + today_new_customer_count: + type: integer + format: int64 + minimum: 0 + description: 今日新增客户数量。 + required: + - pending_customer_count + - active_channel_count + - today_trade_amount + - today_extract_count + - today_new_customer_count + + HomeTrendRequest: + type: object + properties: + date_start: + type: string + format: date + description: 开始日期(含),按北京时间解释。 + example: "2026-06-01" + date_end: + type: string + format: date + description: 结束日期(含),按北京时间解释;日期范围最多 366 天。 + example: "2026-06-30" + required: + - date_start + - date_end + + HomeTrendItem: + type: object + properties: + date: + type: string + format: date + description: 北京时间自然日。 + active_channel_count: + type: integer + format: int64 + minimum: 0 + description: 当日有效通道数;创建时间早于当日结束且过期时间不早于当日开始。 + trade_amount: + type: string + description: 当日完成的成功订单金额,不扣除退款。 + example: "1288.00" + new_customer_count: + type: integer + format: int64 + minimum: 0 + description: 当日新增客户数量。 + extract_count: + type: integer + format: int64 + minimum: 0 + description: 当日提取总量。 + required: + - date + - active_channel_count + - trade_amount + - new_customer_count + - extract_count + + HomeTrendResponse: + type: array + description: 全平台每日趋势;包含日期范围内的每一个自然日,无数据的指标返回零值。 + items: + $ref: "#/components/schemas/HomeTrendItem" + + HomeExtractableResponse: + type: object + description: 全平台所有启用套餐当前可提取总量,长效和短效分别汇总,不区分计费模式。 + properties: + short: + type: integer + format: int64 + minimum: 0 + description: 短效套餐可提取总量。 + long: + type: integer + format: int64 + minimum: 0 + description: 长效套餐可提取总量。 + required: + - short + - long + paths: /api/auth/authorize: get: - tags: [public/auth] + tags: [公共端/认证] summary: 授权确认页占位接口 description: 当前实现只校验参数与客户端,不返回确认页面内容。 parameters: @@ -3576,7 +3687,7 @@ paths: default: $ref: "#/components/responses/PlainTextError" post: - tags: [public/auth] + tags: [公共端/认证] summary: 提交授权确认占位接口 description: 当前实现为占位逻辑。 requestBody: @@ -3593,7 +3704,7 @@ paths: /api/auth/token: post: - tags: [public/auth] + tags: [公共端/认证] summary: 获取访问令牌 description: 支持 `authorization_code`、`client_credentials`、`refresh_token`、`password` 四种授权方式。 requestBody: @@ -3621,7 +3732,7 @@ paths: /api/auth/revoke: post: - tags: [public/auth] + tags: [公共端/认证] summary: 撤销令牌 security: - bearerAuth: [] @@ -3639,7 +3750,7 @@ paths: /api/auth/introspect: post: - tags: [public/auth] + tags: [公共端/认证] summary: 获取当前登录态信息 security: - bearerAuth: [] @@ -3657,7 +3768,7 @@ paths: /api/resource/price: post: - tags: [public/resource] + tags: [公共端/套餐] summary: 计算套餐价格 description: 可匿名调用;如果携带用户 Bearer Token,则价格计算会带入用户折扣/优惠券上下文。 security: @@ -3681,7 +3792,7 @@ paths: /api/trade/check: get: - tags: [public/trade] + tags: [公共端/交易] summary: SSE 检查订单状态 parameters: - name: trade_no @@ -3709,7 +3820,7 @@ paths: /api/inquiry/create: post: - tags: [public/inquiry] + tags: [公共端/咨询] summary: 提交咨询 requestBody: required: true @@ -3725,7 +3836,7 @@ paths: /api/product/list: post: - tags: [public/product] + tags: [公共端/产品] summary: 获取在售产品列表 responses: "200": @@ -3741,7 +3852,7 @@ paths: /api/verify/sms: post: - tags: [client/verify] + tags: [机器端/验证] summary: 发送短信验证码 description: 仅官方客户端可调用,可使用客户端 Basic 鉴权或客户端 Bearer 鉴权。 security: @@ -3763,7 +3874,7 @@ paths: /api/proxy/online: post: - tags: [client/proxy] + tags: [机器端/代理] summary: 代理上线报告 description: 当前实现固定返回“接口暂不可用”。 security: @@ -3785,7 +3896,7 @@ paths: /api/proxy/offline: post: - tags: [client/proxy] + tags: [机器端/代理] summary: 代理下线报告 description: 当前实现固定返回“接口暂不可用”。 security: @@ -3807,7 +3918,7 @@ paths: /api/proxy/update: post: - tags: [client/proxy] + tags: [机器端/代理] summary: 代理状态更新 description: 当前实现固定返回“接口暂不可用”。 security: @@ -3829,7 +3940,7 @@ paths: /api/channel/remove: post: - tags: [client/channel] + tags: [机器端/通道] summary: 删除通道批次 description: 仅官方客户端可调用。 security: @@ -3849,7 +3960,7 @@ paths: /api/article/nav: post: - tags: [client/article] + tags: [机器端/文章] summary: 获取文档导航 description: 仅官方客户端可调用。 security: @@ -3869,7 +3980,7 @@ paths: /api/article/get: post: - tags: [client/article] + tags: [机器端/文章] summary: 获取文档详情 description: 仅官方客户端可调用。支持按文章 ID 或编码查询,二者必须且只能提供一个。 security: @@ -3893,7 +4004,7 @@ paths: /api/user/update: post: - tags: [user/user] + tags: [用户端/用户资料] summary: 更新用户资料 security: - bearerAuth: [] @@ -3911,7 +4022,7 @@ paths: /api/user/update/account: post: - tags: [user/user] + tags: [用户端/用户资料] summary: 更新用户名或密码 security: - bearerAuth: [] @@ -3929,7 +4040,7 @@ paths: /api/user/update/password: post: - tags: [user/user] + tags: [用户端/用户资料] summary: 通过短信验证码修改密码 security: - bearerAuth: [] @@ -3947,7 +4058,7 @@ paths: /api/user/identify: post: - tags: [user/user] + tags: [用户端/用户资料] summary: 发起实名认证 security: - bearerAuth: [] @@ -3969,7 +4080,7 @@ paths: /api/whitelist/list: post: - tags: [user/whitelist] + tags: [用户端/白名单] summary: 获取当前用户白名单 security: - bearerAuth: [] @@ -3990,7 +4101,7 @@ paths: /api/whitelist/create: post: - tags: [user/whitelist] + tags: [用户端/白名单] summary: 新建白名单 security: - bearerAuth: [] @@ -4008,7 +4119,7 @@ paths: /api/whitelist/update: post: - tags: [user/whitelist] + tags: [用户端/白名单] summary: 更新白名单 security: - bearerAuth: [] @@ -4026,7 +4137,7 @@ paths: /api/whitelist/remove: post: - tags: [user/whitelist] + tags: [用户端/白名单] summary: 批量删除白名单 security: - bearerAuth: [] @@ -4046,7 +4157,7 @@ paths: /api/resource/all: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 获取当前用户全部可用套餐 security: - bearerAuth: [] @@ -4064,7 +4175,7 @@ paths: /api/resource/list: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 分页查询当前用户长短效套餐 security: - bearerAuth: [] @@ -4085,7 +4196,7 @@ paths: /api/resource/list/short: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 分页查询当前用户短效套餐 security: - bearerAuth: [] @@ -4106,7 +4217,7 @@ paths: /api/resource/list/long: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 分页查询当前用户长效套餐 security: - bearerAuth: [] @@ -4127,7 +4238,7 @@ paths: /api/resource/create: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 使用余额购买套餐 security: - bearerAuth: [] @@ -4145,7 +4256,7 @@ paths: /api/resource/update/checkip: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 修改套餐 IP 校验开关 security: - bearerAuth: [] @@ -4163,7 +4274,7 @@ paths: /api/resource/statistics/free: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 统计当前用户每日可用额度 security: - bearerAuth: [] @@ -4179,7 +4290,7 @@ paths: /api/resource/statistics/usage: post: - tags: [user/resource] + tags: [用户端/套餐] summary: 统计当前用户使用量 security: - bearerAuth: [] @@ -4202,7 +4313,7 @@ paths: /api/batch/page: post: - tags: [user/batch] + tags: [用户端/批次] summary: 分页查询当前用户提取记录 security: - bearerAuth: [] @@ -4223,7 +4334,7 @@ paths: /api/channel/list: post: - tags: [user/channel] + tags: [用户端/通道] summary: 分页查询当前用户通道 security: - bearerAuth: [] @@ -4244,7 +4355,7 @@ paths: /api/channel/create: post: - tags: [user/channel] + tags: [用户端/通道] summary: 创建通道 description: 当前实现不要求登录,但会校验套餐、实名认证和白名单条件。 requestBody: @@ -4267,7 +4378,7 @@ paths: /api/channel/create/v2: post: - tags: [user/channel] + tags: [用户端/通道] summary: 创建通道 v2 description: 使用 `resource_no` 替代 `resource_id`。 requestBody: @@ -4290,7 +4401,7 @@ paths: /api/trade/create: post: - tags: [user/trade] + tags: [用户端/交易] summary: 创建订单 security: - bearerAuth: [] @@ -4312,7 +4423,7 @@ paths: /api/trade/complete: post: - tags: [user/trade] + tags: [用户端/交易] summary: 完成订单 security: - bearerAuth: [] @@ -4330,7 +4441,7 @@ paths: /api/trade/cancel: post: - tags: [user/trade] + tags: [用户端/交易] summary: 取消订单 security: - bearerAuth: [] @@ -4348,7 +4459,7 @@ paths: /api/bill/list: post: - tags: [user/bill] + tags: [用户端/账单] summary: 分页查询当前用户账单 security: - bearerAuth: [] @@ -4369,7 +4480,7 @@ paths: /api/balance/page: post: - tags: [user/balance] + tags: [用户端/余额变动] summary: 分页查询当前用户余额变动 security: - bearerAuth: [] @@ -4390,7 +4501,7 @@ paths: /api/coupon-user/page: post: - tags: [user/coupon-user] + tags: [用户端/已发放优惠券] summary: 分页查询当前用户已发放优惠券 security: - bearerAuth: [] @@ -4411,7 +4522,7 @@ paths: /api/coupon-user/get: post: - tags: [user/coupon-user] + tags: [用户端/已发放优惠券] summary: 获取当前用户优惠券详情 security: - bearerAuth: [] @@ -4433,7 +4544,7 @@ paths: /api/announcement/list: post: - tags: [user/announcement] + tags: [用户端/公告] summary: 分页查询公告 security: - bearerAuth: [] @@ -4454,7 +4565,7 @@ paths: /api/verify/sms/password: post: - tags: [user/verify] + tags: [用户端/验证] summary: 发送修改密码短信验证码 security: - bearerAuth: [] @@ -4466,9 +4577,66 @@ paths: default: $ref: "#/components/responses/PlainTextError" + /api/admin/home/overview: + post: + tags: [管理端/首页] + summary: 获取后台首页概览统计 + description: 返回全平台当前及今日概览;今日按北京时间自然日统计。 + security: + - bearerAuth: [] + responses: + "200": + description: 首页概览统计 + content: + application/json: + schema: + $ref: "#/components/schemas/HomeOverviewResponse" + default: + $ref: "#/components/responses/PlainTextError" + + /api/admin/home/trend: + post: + tags: [管理端/首页] + summary: 获取后台首页每日趋势 + description: 按前端传入的北京时间日期范围,返回全平台逐日统计数据;日期范围最多 366 天。 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HomeTrendRequest" + responses: + "200": + description: 每日趋势统计 + content: + application/json: + schema: + $ref: "#/components/schemas/HomeTrendResponse" + default: + $ref: "#/components/responses/PlainTextError" + + /api/admin/home/extractable: + post: + tags: [管理端/首页] + summary: 获取全平台可提取总量 + description: 汇总所有启用套餐当前可提取总量,长效和短效分别返回,不区分计费模式。 + security: + - bearerAuth: [] + responses: + "200": + description: 全平台可提取总量 + content: + application/json: + schema: + $ref: "#/components/schemas/HomeExtractableResponse" + default: + $ref: "#/components/responses/PlainTextError" + /api/admin/admin/all: post: - tags: [admin/admin] + tags: [管理端/管理员] summary: 获取管理员列表 security: - bearerAuth: [] @@ -4486,7 +4654,7 @@ paths: /api/admin/admin/page: post: - tags: [admin/admin] + tags: [管理端/管理员] summary: 分页获取管理员 security: - bearerAuth: [] @@ -4507,7 +4675,7 @@ paths: /api/admin/admin/create: post: - tags: [admin/admin] + tags: [管理端/管理员] summary: 创建管理员 security: - bearerAuth: [] @@ -4525,7 +4693,7 @@ paths: /api/admin/admin/update: post: - tags: [admin/admin] + tags: [管理端/管理员] summary: 更新管理员 security: - bearerAuth: [] @@ -4543,7 +4711,7 @@ paths: /api/admin/admin/remove: post: - tags: [admin/admin] + tags: [管理端/管理员] summary: 删除管理员 security: - bearerAuth: [] @@ -4561,7 +4729,7 @@ paths: /api/admin/admin-role/list: post: - tags: [admin/admin-role] + tags: [管理端/管理员角色] summary: 获取管理员角色列表 security: - bearerAuth: [] @@ -4579,7 +4747,7 @@ paths: /api/admin/admin-role/page: post: - tags: [admin/admin-role] + tags: [管理端/管理员角色] summary: 分页获取管理员角色 security: - bearerAuth: [] @@ -4600,7 +4768,7 @@ paths: /api/admin/admin-role/create: post: - tags: [admin/admin-role] + tags: [管理端/管理员角色] summary: 创建管理员角色 security: - bearerAuth: [] @@ -4618,7 +4786,7 @@ paths: /api/admin/admin-role/update: post: - tags: [admin/admin-role] + tags: [管理端/管理员角色] summary: 更新管理员角色 security: - bearerAuth: [] @@ -4636,7 +4804,7 @@ paths: /api/admin/admin-role/remove: post: - tags: [admin/admin-role] + tags: [管理端/管理员角色] summary: 删除管理员角色 security: - bearerAuth: [] @@ -4654,7 +4822,7 @@ paths: /api/admin/permission/list: post: - tags: [admin/permission] + tags: [管理端/权限] summary: 获取权限列表 security: - bearerAuth: [] @@ -4672,7 +4840,7 @@ paths: /api/admin/permission/page: post: - tags: [admin/permission] + tags: [管理端/权限] summary: 分页获取权限 security: - bearerAuth: [] @@ -4693,7 +4861,7 @@ paths: /api/admin/user/page: post: - tags: [admin/user] + tags: [管理端/用户] summary: 分页获取用户 security: - bearerAuth: [] @@ -4714,7 +4882,7 @@ paths: /api/admin/user/page/not-bind: post: - tags: [admin/user] + tags: [管理端/用户] summary: 分页获取未绑定管理员的用户 security: - bearerAuth: [] @@ -4735,7 +4903,7 @@ paths: /api/admin/user/get: post: - tags: [admin/user] + tags: [管理端/用户] summary: 获取单个用户 security: - bearerAuth: [] @@ -4757,7 +4925,7 @@ paths: /api/admin/user/create: post: - tags: [admin/user] + tags: [管理端/用户] summary: 创建用户 security: - bearerAuth: [] @@ -4775,7 +4943,7 @@ paths: /api/admin/user/update: post: - tags: [admin/user] + tags: [管理端/用户] summary: 更新用户 security: - bearerAuth: [] @@ -4793,7 +4961,7 @@ paths: /api/admin/user/remove: post: - tags: [admin/user] + tags: [管理端/用户] summary: 删除用户 security: - bearerAuth: [] @@ -4811,7 +4979,7 @@ paths: /api/admin/user/update/bind: post: - tags: [admin/user] + tags: [管理端/用户] summary: 认领用户 security: - bearerAuth: [] @@ -4829,7 +4997,7 @@ paths: /api/admin/user/update/balance: post: - tags: [admin/user] + tags: [管理端/用户] summary: 直接设置用户余额 security: - bearerAuth: [] @@ -4847,7 +5015,7 @@ paths: /api/admin/user/update/balance-inc: post: - tags: [admin/user] + tags: [管理端/用户] summary: 增加用户余额 security: - bearerAuth: [] @@ -4865,7 +5033,7 @@ paths: /api/admin/user/update/balance-dec: post: - tags: [admin/user] + tags: [管理端/用户] summary: 减少用户余额 security: - bearerAuth: [] @@ -4883,7 +5051,7 @@ paths: /api/admin/resource/page: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询全部长短效套餐 security: - bearerAuth: [] @@ -4904,7 +5072,7 @@ paths: /api/admin/resource/page/of-user: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询指定用户长短效套餐 security: - bearerAuth: [] @@ -4925,7 +5093,7 @@ paths: /api/admin/resource/short/page: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询全部短效套餐 security: - bearerAuth: [] @@ -4946,7 +5114,7 @@ paths: /api/admin/resource/short/page/of-user: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询指定用户短效套餐 security: - bearerAuth: [] @@ -4967,7 +5135,7 @@ paths: /api/admin/resource/long/page: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询全部长效套餐 security: - bearerAuth: [] @@ -4988,7 +5156,7 @@ paths: /api/admin/resource/long/page/of-user: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 分页查询指定用户长效套餐 security: - bearerAuth: [] @@ -5009,7 +5177,7 @@ paths: /api/admin/resource/update: post: - tags: [admin/resource] + tags: [管理端/套餐] summary: 更新套餐状态 security: - bearerAuth: [] @@ -5027,7 +5195,7 @@ paths: /api/admin/batch/page: post: - tags: [admin/batch] + tags: [管理端/批次] summary: 分页查询全部提取记录 security: - bearerAuth: [] @@ -5048,7 +5216,7 @@ paths: /api/admin/batch/page/of-user: post: - tags: [admin/batch] + tags: [管理端/批次] summary: 分页查询指定用户提取记录 security: - bearerAuth: [] @@ -5069,7 +5237,7 @@ paths: /api/admin/channel/page: post: - tags: [admin/channel] + tags: [管理端/通道] summary: 分页查询全部通道 security: - bearerAuth: [] @@ -5090,7 +5258,7 @@ paths: /api/admin/channel/page/of-user: post: - tags: [admin/channel] + tags: [管理端/通道] summary: 分页查询指定用户通道 security: - bearerAuth: [] @@ -5111,7 +5279,7 @@ paths: /api/admin/channel/sync/clear-expired: post: - tags: [admin/channel] + tags: [管理端/通道] summary: 清理指定代理的过期通道 security: - bearerAuth: [] @@ -5133,7 +5301,7 @@ paths: /api/admin/proxy/all: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 获取代理列表 security: - bearerAuth: [] @@ -5151,7 +5319,7 @@ paths: /api/admin/proxy/page: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 分页获取代理 security: - bearerAuth: [] @@ -5172,7 +5340,7 @@ paths: /api/admin/proxy/create: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 创建代理 security: - bearerAuth: [] @@ -5190,7 +5358,7 @@ paths: /api/admin/proxy/update: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 更新代理 security: - bearerAuth: [] @@ -5208,7 +5376,7 @@ paths: /api/admin/proxy/update/status: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 更新代理在线状态 security: - bearerAuth: [] @@ -5226,7 +5394,7 @@ paths: /api/admin/proxy/sync/ports: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 重建端口池 security: - bearerAuth: [] @@ -5244,7 +5412,7 @@ paths: /api/admin/proxy/sync/chains: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 重建代理链 security: - bearerAuth: [] @@ -5262,7 +5430,7 @@ paths: /api/admin/proxy/remove: post: - tags: [admin/proxy] + tags: [管理端/代理] summary: 删除代理 security: - bearerAuth: [] @@ -5280,7 +5448,7 @@ paths: /api/admin/trade/page: post: - tags: [admin/trade] + tags: [管理端/交易] summary: 分页查询全部订单 security: - bearerAuth: [] @@ -5301,7 +5469,7 @@ paths: /api/admin/trade/page/of-user: post: - tags: [admin/trade] + tags: [管理端/交易] summary: 分页查询指定用户订单 security: - bearerAuth: [] @@ -5322,7 +5490,7 @@ paths: /api/admin/trade/complete: post: - tags: [admin/trade] + tags: [管理端/交易] summary: 管理员完成订单 security: - bearerAuth: [] @@ -5340,7 +5508,7 @@ paths: /api/admin/bill/page: post: - tags: [admin/bill] + tags: [管理端/账单] summary: 分页查询全部账单 security: - bearerAuth: [] @@ -5361,7 +5529,7 @@ paths: /api/admin/bill/page/of-user: post: - tags: [admin/bill] + tags: [管理端/账单] summary: 分页查询指定用户账单 security: - bearerAuth: [] @@ -5382,7 +5550,7 @@ paths: /api/admin/balance-activity/page: post: - tags: [admin/balance-activity] + tags: [管理端/余额变动] summary: 分页查询全部余额变动 security: - bearerAuth: [] @@ -5403,7 +5571,7 @@ paths: /api/admin/balance-activity/page/of-user: post: - tags: [admin/balance-activity] + tags: [管理端/余额变动] summary: 分页查询指定用户余额变动 security: - bearerAuth: [] @@ -5424,7 +5592,7 @@ paths: /api/admin/product/all: post: - tags: [admin/product] + tags: [管理端/产品] summary: 获取全部产品 security: - bearerAuth: [] @@ -5442,7 +5610,7 @@ paths: /api/admin/product/create: post: - tags: [admin/product] + tags: [管理端/产品] summary: 创建产品 security: - bearerAuth: [] @@ -5460,7 +5628,7 @@ paths: /api/admin/product/update: post: - tags: [admin/product] + tags: [管理端/产品] summary: 更新产品 security: - bearerAuth: [] @@ -5478,7 +5646,7 @@ paths: /api/admin/product/remove: post: - tags: [admin/product] + tags: [管理端/产品] summary: 删除产品 security: - bearerAuth: [] @@ -5496,7 +5664,7 @@ paths: /api/admin/product/sku/all: post: - tags: [admin/product] + tags: [管理端/产品] summary: 获取产品 SKU 列表 security: - bearerAuth: [] @@ -5519,7 +5687,7 @@ paths: /api/admin/product/sku/page: post: - tags: [admin/product] + tags: [管理端/产品] summary: 分页获取产品 SKU security: - bearerAuth: [] @@ -5540,7 +5708,7 @@ paths: /api/admin/product/sku/create: post: - tags: [admin/product] + tags: [管理端/产品] summary: 创建产品 SKU security: - bearerAuth: [] @@ -5558,7 +5726,7 @@ paths: /api/admin/product/sku/update: post: - tags: [admin/product] + tags: [管理端/产品] summary: 更新产品 SKU security: - bearerAuth: [] @@ -5576,7 +5744,7 @@ paths: /api/admin/product/sku/update/status: post: - tags: [admin/product] + tags: [管理端/产品] summary: 更新产品 SKU 状态 security: - bearerAuth: [] @@ -5594,7 +5762,7 @@ paths: /api/admin/product/sku/remove: post: - tags: [admin/product] + tags: [管理端/产品] summary: 删除产品 SKU security: - bearerAuth: [] @@ -5612,7 +5780,7 @@ paths: /api/admin/product/sku/update/discount/batch: post: - tags: [admin/product] + tags: [管理端/产品] summary: 批量更新产品 SKU 折扣 security: - bearerAuth: [] @@ -5630,7 +5798,7 @@ paths: /api/admin/discount/all: post: - tags: [admin/discount] + tags: [管理端/折扣] summary: 获取折扣列表 security: - bearerAuth: [] @@ -5648,7 +5816,7 @@ paths: /api/admin/discount/page: post: - tags: [admin/discount] + tags: [管理端/折扣] summary: 分页获取折扣 security: - bearerAuth: [] @@ -5669,7 +5837,7 @@ paths: /api/admin/discount/create: post: - tags: [admin/discount] + tags: [管理端/折扣] summary: 创建折扣 security: - bearerAuth: [] @@ -5687,7 +5855,7 @@ paths: /api/admin/discount/update: post: - tags: [admin/discount] + tags: [管理端/折扣] summary: 更新折扣 security: - bearerAuth: [] @@ -5705,7 +5873,7 @@ paths: /api/admin/discount/remove: post: - tags: [admin/discount] + tags: [管理端/折扣] summary: 删除折扣 security: - bearerAuth: [] @@ -5723,7 +5891,7 @@ paths: /api/admin/coupon/all: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 获取优惠券列表 security: - bearerAuth: [] @@ -5741,7 +5909,7 @@ paths: /api/admin/coupon/page: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 分页获取优惠券 security: - bearerAuth: [] @@ -5762,7 +5930,7 @@ paths: /api/admin/coupon/create: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 创建优惠券 security: - bearerAuth: [] @@ -5780,7 +5948,7 @@ paths: /api/admin/coupon/update: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 更新优惠券 security: - bearerAuth: [] @@ -5798,7 +5966,7 @@ paths: /api/admin/coupon/remove: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 删除优惠券 security: - bearerAuth: [] @@ -5816,7 +5984,7 @@ paths: /api/admin/coupon/update/assign: post: - tags: [admin/coupon] + tags: [管理端/优惠券] summary: 发放优惠券给用户 security: - bearerAuth: [] @@ -5834,7 +6002,7 @@ paths: /api/admin/coupon-user/page: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 分页查询全部已发放优惠券 security: - bearerAuth: [] @@ -5855,7 +6023,7 @@ paths: /api/admin/coupon-user/page/of-user: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 分页查询指定用户已发放优惠券 security: - bearerAuth: [] @@ -5876,7 +6044,7 @@ paths: /api/admin/coupon-user/get: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 获取已发放优惠券详情 security: - bearerAuth: [] @@ -5898,7 +6066,7 @@ paths: /api/admin/coupon-user/create: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 手动创建已发放优惠券 security: - bearerAuth: [] @@ -5916,7 +6084,7 @@ paths: /api/admin/coupon-user/update: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 更新已发放优惠券 security: - bearerAuth: [] @@ -5934,7 +6102,7 @@ paths: /api/admin/coupon-user/remove: post: - tags: [admin/coupon-user] + tags: [管理端/已发放优惠券] summary: 删除已发放优惠券 security: - bearerAuth: [] @@ -5952,7 +6120,7 @@ paths: /api/admin/article/page: post: - tags: [admin/article] + tags: [管理端/文章] summary: 分页查询文章 security: - bearerAuth: [] @@ -5973,7 +6141,7 @@ paths: /api/admin/article/get: post: - tags: [admin/article] + tags: [管理端/文章] summary: 获取文章详情 security: - bearerAuth: [] @@ -5995,7 +6163,7 @@ paths: /api/admin/article/create: post: - tags: [admin/article] + tags: [管理端/文章] summary: 创建文章 security: - bearerAuth: [] @@ -6013,7 +6181,7 @@ paths: /api/admin/article/update: post: - tags: [admin/article] + tags: [管理端/文章] summary: 更新文章 security: - bearerAuth: [] @@ -6031,7 +6199,7 @@ paths: /api/admin/article/remove: post: - tags: [admin/article] + tags: [管理端/文章] summary: 删除文章 security: - bearerAuth: [] @@ -6049,7 +6217,7 @@ paths: /api/admin/article/upload: post: - tags: [admin/article] + tags: [管理端/文章] summary: 上传文章图片 security: - bearerAuth: [] @@ -6077,7 +6245,7 @@ paths: /api/admin/article-group/page: post: - tags: [admin/article-group] + tags: [管理端/文章分组] summary: 分页查询文章分组 security: - bearerAuth: [] @@ -6098,7 +6266,7 @@ paths: /api/admin/article-group/list: post: - tags: [admin/article-group] + tags: [管理端/文章分组] summary: 获取文章分组列表 security: - bearerAuth: [] @@ -6116,7 +6284,7 @@ paths: /api/admin/article-group/create: post: - tags: [admin/article-group] + tags: [管理端/文章分组] summary: 创建文章分组 security: - bearerAuth: [] @@ -6134,7 +6302,7 @@ paths: /api/admin/article-group/update: post: - tags: [admin/article-group] + tags: [管理端/文章分组] summary: 更新文章分组 security: - bearerAuth: [] @@ -6152,7 +6320,7 @@ paths: /api/admin/article-group/remove: post: - tags: [admin/article-group] + tags: [管理端/文章分组] summary: 删除文章分组 security: - bearerAuth: [] @@ -6170,7 +6338,7 @@ paths: /callback/identify: get: - tags: [callback/identify] + tags: [回调/实名认证] summary: 实名认证回调 parameters: - name: id diff --git a/web/handlers/home.go b/web/handlers/home.go new file mode 100644 index 0000000..3f0190a --- /dev/null +++ b/web/handlers/home.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "platform/web/auth" + g "platform/web/globals" + s "platform/web/services" + + "github.com/gofiber/fiber/v2" +) + +// HomeOverview 后台首页概览统计 +func HomeOverview(c *fiber.Ctx) error { + if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil { + return err + } + + resp, err := s.Home.Overview() + if err != nil { + return err + } + + return c.JSON(resp) +} + +// HomeTrend 后台首页每日趋势统计 +func HomeTrend(c *fiber.Ctx) error { + if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil { + return err + } + + var req s.HomeTrendReq + if err := g.Validator.ParseBody(c, &req); err != nil { + return err + } + + resp, err := s.Home.Trend(req) + if err != nil { + return err + } + + return c.JSON(resp) +} + +// HomeExtractable 后台首页全局可提取统计 +func HomeExtractable(c *fiber.Ctx) error { + if _, err := auth.GetAuthCtx(c).PermitAdmin(); err != nil { + return err + } + + resp, err := s.Home.Extractable() + if err != nil { + return err + } + + return c.JSON(resp) +} diff --git a/web/routes.go b/web/routes.go index dd57a6f..b4bb671 100644 --- a/web/routes.go +++ b/web/routes.go @@ -170,6 +170,12 @@ func userRouter(api fiber.Router) { func adminRouter(api fiber.Router) { api = api.Group("/admin") + // home 后台首页 + var home = api.Group("/home") + home.Post("/overview", handlers.HomeOverview) + home.Post("/trend", handlers.HomeTrend) + home.Post("/extractable", handlers.HomeExtractable) + // admin 管理员 var admin = api.Group("/admin") admin.Post("/all", handlers.AllAdminByAdmin) diff --git a/web/services/home.go b/web/services/home.go new file mode 100644 index 0000000..f378573 --- /dev/null +++ b/web/services/home.go @@ -0,0 +1,424 @@ +package services + +import ( + "fmt" + "platform/web/core" + g "platform/web/globals" + m "platform/web/models" + "time" + + "github.com/shopspring/decimal" +) + +const homeMaxTrendDays = 366 + +var Home = &homeService{} + +type homeService struct{} + +type HomeOverviewResp struct { + PendingCustomerCount int64 `json:"pending_customer_count"` + ActiveChannelCount int64 `json:"active_channel_count"` + TodayTradeAmount string `json:"today_trade_amount"` + TodayExtractCount int64 `json:"today_extract_count"` + TodayNewCustomerCount int64 `json:"today_new_customer_count"` +} + +type HomeTrendReq struct { + DateStart string `json:"date_start" validate:"required"` + DateEnd string `json:"date_end" validate:"required"` +} + +type HomeTrendResp []HomeTrendItem + +type HomeTrendItem struct { + Date string `json:"date"` + ActiveChannelCount int64 `json:"active_channel_count"` + TradeAmount string `json:"trade_amount"` + NewCustomerCount int64 `json:"new_customer_count"` + ExtractCount int64 `json:"extract_count"` +} + +type HomeExtractableResp struct { + Short int64 `json:"short"` + Long int64 `json:"long"` +} + +type homeRange struct { + StartLocal time.Time + EndLocal time.Time + StartUTC time.Time + EndUTC time.Time + Dates []string +} + +type homeCountRow struct { + Date string + Count int64 +} + +type homeAmountRow struct { + Date string + Amount string +} + +func (s *homeService) Overview() (*HomeOverviewResp, error) { + loc := homeLocation() + startLocal := startOfLocalDay(time.Now().In(loc), loc) + rng := homeRange{ + StartLocal: startLocal, + EndLocal: startLocal, + StartUTC: startLocal.UTC(), + EndUTC: startLocal.AddDate(0, 0, 1).UTC(), + Dates: []string{homeDateKey(startLocal, loc)}, + } + + pendingCustomerCount, err := s.pendingCustomerCount() + if err != nil { + return nil, err + } + + activeChannelCount, err := s.activeChannelCount(time.Now().UTC()) + if err != nil { + return nil, err + } + + tradeAmount, err := s.tradeAmount(rng.StartUTC, rng.EndUTC) + if err != nil { + return nil, err + } + + extractCount, err := s.extractCount(rng.StartUTC, rng.EndUTC) + if err != nil { + return nil, err + } + + newCustomerCount, err := s.newCustomerCount(rng.StartUTC, rng.EndUTC) + if err != nil { + return nil, err + } + + return &HomeOverviewResp{ + PendingCustomerCount: pendingCustomerCount, + ActiveChannelCount: activeChannelCount, + TodayTradeAmount: tradeAmount, + TodayExtractCount: extractCount, + TodayNewCustomerCount: newCustomerCount, + }, nil +} + +func (s *homeService) Trend(req HomeTrendReq) (HomeTrendResp, error) { + rng, err := newHomeRange(req.DateStart, req.DateEnd) + if err != nil { + return nil, err + } + + items := make(HomeTrendResp, 0, len(rng.Dates)) + byDate := make(map[string]int, len(rng.Dates)) + for _, date := range rng.Dates { + byDate[date] = len(items) + items = append(items, HomeTrendItem{ + Date: date, + TradeAmount: "0.00", + }) + } + + activeChannels, err := s.dailyActiveChannelCounts(rng) + if err != nil { + return nil, err + } + for _, row := range activeChannels { + if index, ok := byDate[row.Date]; ok { + items[index].ActiveChannelCount = row.Count + } + } + + trades, err := s.dailyTradeAmounts(rng) + if err != nil { + return nil, err + } + for _, row := range trades { + if index, ok := byDate[row.Date]; ok { + items[index].TradeAmount = normalizeDecimalString(row.Amount) + } + } + + newCustomers, err := s.dailyNewCustomerCounts(rng) + if err != nil { + return nil, err + } + for _, row := range newCustomers { + if index, ok := byDate[row.Date]; ok { + items[index].NewCustomerCount = row.Count + } + } + + extracts, err := s.dailyExtractCounts(rng) + if err != nil { + return nil, err + } + for _, row := range extracts { + if index, ok := byDate[row.Date]; ok { + items[index].ExtractCount = row.Count + } + } + + return items, nil +} + +func (s *homeService) Extractable() (*HomeExtractableResp, error) { + loc := homeLocation() + now := time.Now().In(loc) + + var resources []*m.Resource + err := g.DB. + Preload("Short"). + Preload("Long"). + Where("active = ?", true). + Find(&resources).Error + if err != nil { + return nil, err + } + + resp := &HomeExtractableResp{} + for _, resource := range resources { + switch resource.Type { + case m.ResourceTypeShort: + resp.Short += calcShortExtractable(resource.Short, now, loc) + case m.ResourceTypeLong: + resp.Long += calcLongExtractable(resource.Long, now, loc) + } + } + + return resp, nil +} + +func (s *homeService) pendingCustomerCount() (int64, error) { + var count int64 + err := g.DB.Model(&m.User{}).Where("admin_id IS NULL").Count(&count).Error + return count, err +} + +func (s *homeService) activeChannelCount(now time.Time) (int64, error) { + var count int64 + err := g.DB.Model(&m.Channel{}).Where("expired_at > ?", now.UTC()).Count(&count).Error + return count, err +} + +func (s *homeService) tradeAmount(startUTC, endUTC time.Time) (string, error) { + var amount string + err := g.DB.Raw(` + SELECT COALESCE(SUM(payment), 0)::text + FROM "trade" + WHERE deleted_at IS NULL + AND status = ? + AND completed_at >= ? + AND completed_at < ? + `, int(m.TradeStatusSuccess), startUTC, endUTC).Scan(&amount).Error + return normalizeDecimalString(amount), err +} + +func (s *homeService) extractCount(startUTC, endUTC time.Time) (int64, error) { + var count int64 + err := g.DB.Raw(` + SELECT COALESCE(SUM(count), 0) + FROM logs_user_usage + WHERE time >= ? + AND time < ? + `, startUTC, endUTC).Scan(&count).Error + return count, err +} + +func (s *homeService) newCustomerCount(startUTC, endUTC time.Time) (int64, error) { + var count int64 + err := g.DB.Model(&m.User{}). + Where("created_at >= ? AND created_at < ?", startUTC, endUTC). + Count(&count).Error + return count, err +} + +func (s *homeService) dailyActiveChannelCounts(rng homeRange) ([]homeCountRow, error) { + var rows []homeCountRow + err := g.DB.Raw(` + WITH days AS ( + SELECT generate_series(?::timestamptz, ?::timestamptz - interval '1 day', interval '1 day') AS day_start + ) + SELECT + to_char(days.day_start AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, + COUNT(channel.id) AS count + FROM days + LEFT JOIN channel + ON channel.deleted_at IS NULL + AND channel.created_at < days.day_start + interval '1 day' + AND channel.expired_at >= days.day_start + GROUP BY days.day_start + ORDER BY days.day_start + `, rng.StartUTC, rng.EndUTC).Scan(&rows).Error + return rows, err +} + +func (s *homeService) dailyTradeAmounts(rng homeRange) ([]homeAmountRow, error) { + var rows []homeAmountRow + err := g.DB.Raw(` + SELECT + to_char(completed_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, + COALESCE(SUM(payment), 0)::text AS amount + FROM "trade" + WHERE deleted_at IS NULL + AND status = ? + AND completed_at >= ? + AND completed_at < ? + GROUP BY date + ORDER BY date + `, int(m.TradeStatusSuccess), rng.StartUTC, rng.EndUTC).Scan(&rows).Error + return rows, err +} + +func (s *homeService) dailyNewCustomerCounts(rng homeRange) ([]homeCountRow, error) { + var rows []homeCountRow + err := g.DB.Raw(` + SELECT + to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, + COUNT(*) AS count + FROM "user" + WHERE deleted_at IS NULL + AND created_at >= ? + AND created_at < ? + GROUP BY date + ORDER BY date + `, rng.StartUTC, rng.EndUTC).Scan(&rows).Error + return rows, err +} + +func (s *homeService) dailyExtractCounts(rng homeRange) ([]homeCountRow, error) { + var rows []homeCountRow + err := g.DB.Raw(` + SELECT + to_char(time AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, + COALESCE(SUM(count), 0) AS count + FROM logs_user_usage + WHERE time >= ? + AND time < ? + GROUP BY date + ORDER BY date + `, rng.StartUTC, rng.EndUTC).Scan(&rows).Error + return rows, err +} + +func newHomeRange(dateStart, dateEnd string) (homeRange, error) { + loc := homeLocation() + + startLocal, err := time.ParseInLocation(time.DateOnly, dateStart, loc) + if err != nil { + return homeRange{}, core.NewBizErr("date_start 必须是 YYYY-MM-DD 格式", err) + } + endLocal, err := time.ParseInLocation(time.DateOnly, dateEnd, loc) + if err != nil { + return homeRange{}, core.NewBizErr("date_end 必须是 YYYY-MM-DD 格式", err) + } + if endLocal.Before(startLocal) { + return homeRange{}, core.NewBizErr("date_end 不能早于 date_start") + } + + days := int(endLocal.Sub(startLocal).Hours()/24) + 1 + if days > homeMaxTrendDays { + return homeRange{}, core.NewBizErr(fmt.Sprintf("日期范围不能超过 %d 天", homeMaxTrendDays)) + } + + dates := make([]string, 0, days) + for day := startLocal; !day.After(endLocal); day = day.AddDate(0, 0, 1) { + dates = append(dates, homeDateKey(day, loc)) + } + + return homeRange{ + StartLocal: startLocal, + EndLocal: endLocal, + StartUTC: startLocal.UTC(), + EndUTC: endLocal.AddDate(0, 0, 1).UTC(), + Dates: dates, + }, nil +} + +func calcShortExtractable(resource *m.ResourceShort, now time.Time, loc *time.Location) int64 { + if resource == nil { + return 0 + } + return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc) +} + +func calcLongExtractable(resource *m.ResourceLong, now time.Time, loc *time.Location) int64 { + if resource == nil { + return 0 + } + return calcExtractable(resource.Type, resource.Quota, resource.Used, resource.Daily, resource.LastAt, resource.ExpireAt, now, loc) +} + +func calcExtractable(mode m.ResourceMode, quota, used, daily int32, lastAt, expireAt *time.Time, now time.Time, loc *time.Location) int64 { + switch mode { + case m.ResourceModeQuota: + return maxInt64(int64(quota)-int64(used), 0) + + case m.ResourceModeTime: + if expireAt == nil || !expireAt.After(now) { + return 0 + } + + todayRemaining := int64(quota) + if lastAt != nil && sameLocalDate(*lastAt, now, loc) { + todayRemaining = maxInt64(int64(quota)-int64(daily), 0) + } + + todayStart := startOfLocalDay(now, loc) + expireStart := startOfLocalDay(expireAt.In(loc), loc) + futureDays := int64(expireStart.Sub(todayStart).Hours() / 24) + if futureDays < 0 { + futureDays = 0 + } + + return todayRemaining + futureDays*int64(quota) + } + + return 0 +} + +func homeLocation() *time.Location { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("Asia/Shanghai", 8*60*60) + } + return loc +} + +func startOfLocalDay(t time.Time, loc *time.Location) time.Time { + y, m, d := t.In(loc).Date() + return time.Date(y, m, d, 0, 0, 0, 0, loc) +} + +func sameLocalDate(a, b time.Time, loc *time.Location) bool { + ay, am, ad := a.In(loc).Date() + by, bm, bd := b.In(loc).Date() + return ay == by && am == bm && ad == bd +} + +func homeDateKey(t time.Time, loc *time.Location) string { + return t.In(loc).Format(time.DateOnly) +} + +func normalizeDecimalString(s string) string { + if s == "" { + return "0.00" + } + + d, err := decimal.NewFromString(s) + if err != nil { + return "0.00" + } + return d.StringFixed(2) +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/web/services/home_test.go b/web/services/home_test.go new file mode 100644 index 0000000..2766241 --- /dev/null +++ b/web/services/home_test.go @@ -0,0 +1,87 @@ +package services + +import ( + m "platform/web/models" + "testing" + "time" +) + +func TestNewHomeRange(t *testing.T) { + rng, err := newHomeRange("2026-07-20", "2026-07-21") + if err != nil { + t.Fatalf("newHomeRange returned error: %v", err) + } + + if got, want := len(rng.Dates), 2; got != want { + t.Fatalf("len(Dates) = %d, want %d", got, want) + } + if got, want := rng.Dates[0], "2026-07-20"; got != want { + t.Fatalf("Dates[0] = %s, want %s", got, want) + } + if got, want := rng.Dates[1], "2026-07-21"; got != want { + t.Fatalf("Dates[1] = %s, want %s", got, want) + } + if got, want := rng.StartUTC.Format(time.RFC3339), "2026-07-19T16:00:00Z"; got != want { + t.Fatalf("StartUTC = %s, want %s", got, want) + } + if got, want := rng.EndUTC.Format(time.RFC3339), "2026-07-21T16:00:00Z"; got != want { + t.Fatalf("EndUTC = %s, want %s", got, want) + } +} + +func TestNewHomeRangeRejectsInvalidRange(t *testing.T) { + _, err := newHomeRange("2026-07-21", "2026-07-20") + if err == nil { + t.Fatal("newHomeRange expected error") + } +} + +func TestCalcExtractableQuota(t *testing.T) { + loc := homeLocation() + now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc) + + got := calcExtractable(m.ResourceModeQuota, 100, 30, 0, nil, nil, now, loc) + if want := int64(70); got != want { + t.Fatalf("calcExtractable quota = %d, want %d", got, want) + } + + got = calcExtractable(m.ResourceModeQuota, 100, 130, 0, nil, nil, now, loc) + if want := int64(0); got != want { + t.Fatalf("calcExtractable exhausted quota = %d, want %d", got, want) + } +} + +func TestCalcExtractableTimeCountsTodayAndExpiryDate(t *testing.T) { + loc := homeLocation() + now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc) + lastAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc) + expireAt := time.Date(2026, 7, 23, 12, 0, 0, 0, loc) + + got := calcExtractable(m.ResourceModeTime, 100, 0, 30, &lastAt, &expireAt, now, loc) + if want := int64(270); got != want { + t.Fatalf("calcExtractable time = %d, want %d", got, want) + } +} + +func TestCalcExtractableTimeResetsDailyWhenLastAtIsNotToday(t *testing.T) { + loc := homeLocation() + now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc) + lastAt := time.Date(2026, 7, 20, 23, 59, 59, 0, loc) + expireAt := time.Date(2026, 7, 21, 23, 0, 0, 0, loc) + + got := calcExtractable(m.ResourceModeTime, 100, 0, 80, &lastAt, &expireAt, now, loc) + if want := int64(100); got != want { + t.Fatalf("calcExtractable time reset = %d, want %d", got, want) + } +} + +func TestCalcExtractableTimeExpired(t *testing.T) { + loc := homeLocation() + now := time.Date(2026, 7, 21, 10, 0, 0, 0, loc) + expireAt := time.Date(2026, 7, 21, 9, 0, 0, 0, loc) + + got := calcExtractable(m.ResourceModeTime, 100, 0, 0, nil, &expireAt, now, loc) + if want := int64(0); got != want { + t.Fatalf("calcExtractable expired time = %d, want %d", got, want) + } +}