Compare commits
12 Commits
25cacf0bca
...
v1.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d552f3bc67 | |||
| a456686551 | |||
| 2f0c6e6cb7 | |||
| 5103c08c2b | |||
| 83d38956ca | |||
| d961538027 | |||
| 5d00111887 | |||
| 3de4947207 | |||
| f42e5616b4 | |||
| 5bb65216a9 | |||
| 9c6b07ad8c | |||
| 49d2df54f5 |
@@ -59,3 +59,4 @@ SFTPAY_ROUTE_ID=
|
||||
SFTPAY_APP_PRIVATE_KEY=
|
||||
SFTPAY_PUBLIC_KEY=
|
||||
SFTPAY_RETURN_URL=
|
||||
SFTPAY_NOTIFY_URL=
|
||||
|
||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
// 使用 IntelliSense 了解相关属性。
|
||||
// 悬停以查看现有属性的描述。
|
||||
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "main",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"program": "cmd/main",
|
||||
}
|
||||
]
|
||||
}
|
||||
45
README.md
45
README.md
@@ -1,5 +1,30 @@
|
||||
## TODO
|
||||
|
||||
清除代理的并发问题,redis 归还后,还有可能 gost 没有完成解除占用,导致端口冲突,考虑先等几秒再释放 redis
|
||||
|
||||
现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划
|
||||
|
||||
后台首页接口:
|
||||
|
||||
- 待认领客户数量
|
||||
- 活跃代理数
|
||||
- 今日交易额
|
||||
- 今日提取数量
|
||||
- 今日新增客户
|
||||
- 每日活跃代理
|
||||
- 每日交易额
|
||||
- 每日新增客户
|
||||
- 每日提取量
|
||||
- 全局可提取统计(包时套餐计算最大可提取,每日提取上限 * 剩余可提取天数)
|
||||
- 长效套餐
|
||||
- 短效套餐
|
||||
- 最近活跃用户(七日内 ip 使用量)
|
||||
|
||||
---
|
||||
|
||||
关闭支付弹窗
|
||||
后台限制 ip
|
||||
|
||||
- edge.area_id 可为空,代表节点无固定地区
|
||||
- 后台展示 mac, ip:port,实际地区
|
||||
|
||||
@@ -29,20 +54,24 @@
|
||||
1. 创建 model 文件
|
||||
2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中
|
||||
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||
- 为有必要的字段添加索引
|
||||
- 为数据表及其字段添加注释
|
||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||
|
||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||
- 为有必要的字段添加索引
|
||||
- 为数据表及其字段添加注释
|
||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||
|
||||
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
|
||||
|
||||
### 更新数据表流程
|
||||
|
||||
1. 更新 model 文件
|
||||
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||
- 为有必要的字段添加索引
|
||||
- 为数据表及其字段添加注释
|
||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||
|
||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||
- 为有必要的字段添加索引
|
||||
- 为数据表及其字段添加注释
|
||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||
|
||||
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
|
||||
|
||||
### 新增接口或修改接口权限
|
||||
|
||||
@@ -40,14 +40,6 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
gost:
|
||||
image: gogost/gost
|
||||
command: >
|
||||
-api test:test@:9700
|
||||
ports:
|
||||
- "9700:9700"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
732
docs/api.yaml
732
docs/api.yaml
File diff suppressed because it is too large
Load Diff
2
go.mod
2
go.mod
@@ -21,7 +21,6 @@ require (
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/smartwalle/alipay/v3 v3.2.28
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||
@@ -85,6 +84,7 @@ require (
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
|
||||
@@ -184,6 +184,7 @@ values
|
||||
('许昌', 2, (select id from area where name = '河南')),
|
||||
('郑州', 2, (select id from area where name = '河南')),
|
||||
('驻马店', 2, (select id from area where name = '河南')),
|
||||
('平顶山', 2, (select id from area where name = '河南')),
|
||||
('鹤壁', 2, (select id from area where name = '河南')),
|
||||
('丽水', 2, (select id from area where name = '浙江')),
|
||||
('台州', 2, (select id from area where name = '浙江')),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -251,6 +251,10 @@ insert into permission (parent_id, name, description, sort) values
|
||||
insert into permission (parent_id, name, description, sort) values
|
||||
((select id from permission where name = 'resource:long' and deleted_at is null), 'resource:long:read', '读取用户长效动态套餐列表', 1);
|
||||
|
||||
-- resource:read 子权限
|
||||
insert into permission (parent_id, name, description, sort) values
|
||||
((select id from permission where name = 'resource:read' and deleted_at is null), 'resource:read:of_user', '读取指定用户的用户套餐列表', 1);
|
||||
|
||||
-- user:read 子权限
|
||||
insert into permission (parent_id, name, description, sort) values
|
||||
((select id from permission where name = 'user:read' and deleted_at is null), 'user:read:one', '读取单个用户', 1),
|
||||
|
||||
@@ -175,6 +175,7 @@ drop table if exists article cascade;
|
||||
create table article (
|
||||
id int generated by default as identity primary key,
|
||||
group_id int not null,
|
||||
code text,
|
||||
title text not null,
|
||||
content text,
|
||||
sort int not null default 0,
|
||||
@@ -183,6 +184,7 @@ create table article (
|
||||
updated_at timestamptz default current_timestamp,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
create unique index udx_article_code on article (code) where code is not null and deleted_at is null;
|
||||
create index idx_article_group_id on article (group_id) where deleted_at is null;
|
||||
create index idx_article_status on article (status) where deleted_at is null;
|
||||
create index idx_article_sort on article (sort) where deleted_at is null;
|
||||
@@ -191,6 +193,7 @@ create index idx_article_sort on article (sort) where deleted_at is null;
|
||||
comment on table article is '文章表';
|
||||
comment on column article.id is '文章ID';
|
||||
comment on column article.group_id is '分组ID';
|
||||
comment on column article.code is '文章编码,用于固定引用链接';
|
||||
comment on column article.title is '文章标题';
|
||||
comment on column article.content is '文章内容';
|
||||
comment on column article.sort is '文章排序';
|
||||
@@ -614,8 +617,8 @@ create table proxy (
|
||||
updated_at timestamptz default current_timestamp,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
create unique index udx_proxy_mac on proxy (mac) where deleted_at is null;
|
||||
create unique index udx_proxy_ip on proxy (ip) where deleted_at is null;
|
||||
create index idx_proxy_mac on proxy (mac) where deleted_at is null;
|
||||
create index idx_proxy_ip on proxy (ip) where deleted_at is null;
|
||||
create index idx_proxy_created_at on proxy (created_at) where deleted_at is null;
|
||||
|
||||
-- proxy表字段注释
|
||||
|
||||
@@ -26,9 +26,10 @@ const (
|
||||
ScopeDiscountRead = string("discount:read") // 读取折扣列表
|
||||
ScopeDiscountWrite = string("discount:write") // 写入折扣
|
||||
|
||||
ScopeResource = string("resource") // 用户套餐
|
||||
ScopeResourceRead = string("resource:read") // 读取用户套餐列表
|
||||
ScopeResourceWrite = string("resource:write") // 写入用户套餐
|
||||
ScopeResource = string("resource") // 用户套餐
|
||||
ScopeResourceRead = string("resource:read") // 读取用户套餐列表
|
||||
ScopeResourceWrite = string("resource:write") // 写入用户套餐
|
||||
ScopeResourceReadOfUser = string("resource:read:of_user") // 读取指定用户的用户套餐列表
|
||||
|
||||
ScopeResourceShort = string("resource:short") // 短效动态套餐
|
||||
ScopeResourceShortRead = string("resource:short:read") // 读取用户短效动态套餐列表
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package events
|
||||
|
||||
import "github.com/hibiken/asynq"
|
||||
|
||||
const RefreshEdge = "edge:refresh"
|
||||
|
||||
func NewRefreshEdge() *asynq.Task {
|
||||
return asynq.NewTask(RefreshEdge, nil)
|
||||
}
|
||||
// func NewRefreshEdge() *asynq.Task {
|
||||
// return asynq.NewTask(RefreshEdge, nil)
|
||||
// }
|
||||
|
||||
@@ -42,7 +42,7 @@ type gostClient struct {
|
||||
var GostInitializer = func(host string, port int, pathPrefix, username, password string) GostClient {
|
||||
baseURL := strings.TrimSpace(host)
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = fmt.Sprintf("http://%s:%d", baseURL, port)
|
||||
baseURL = fmt.Sprintf("https://%s", baseURL)
|
||||
}
|
||||
|
||||
return &gostClient{
|
||||
@@ -89,6 +89,8 @@ type GostServiceConfig struct {
|
||||
Handler GostHandlerConfig `json:"handler"`
|
||||
Listener GostListenerConfig `json:"listener"`
|
||||
Recorders []GostRecorderConfig `json:"recorders,omitempty"`
|
||||
Limiter string `json:"limiter,omitempty"`
|
||||
Bypasses []string `json:"bypasses,omitempty"`
|
||||
}
|
||||
|
||||
type GostHandlerConfig struct {
|
||||
|
||||
@@ -443,3 +443,11 @@ const (
|
||||
SftRefundIng SftTradeState = "REFUND_ING"
|
||||
SftTradeClosed SftTradeState = "TRADE_CLOSED"
|
||||
)
|
||||
|
||||
type SftNotifyType string
|
||||
|
||||
var (
|
||||
SftNotifyTypeTrade = SftNotifyType("sft.trade.notify")
|
||||
SftNotifyTypeRefund = SftNotifyType("sft.refund.notify")
|
||||
SftNotifyTypeDivision = SftNotifyType("sft.division.notify")
|
||||
)
|
||||
|
||||
@@ -31,12 +31,12 @@ func GetArticle(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var req core.IdReq
|
||||
var req s.GetArticleReq
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
article, err := s.Article.GetPublic(req.Id)
|
||||
article, err := s.Article.GetPublic(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
56
web/handlers/home.go
Normal file
56
web/handlers/home.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -15,6 +15,176 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// PageResource 分页查询当前用户长短效套餐
|
||||
func PageResource(c *fiber.Ctx) error {
|
||||
authCtx, err := auth.GetAuthCtx(c).PermitUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := new(PageResourceReq)
|
||||
if err := c.BodyParser(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
short := q.ResourceShort.As(q.Resource.Short.Name())
|
||||
long := q.ResourceLong.As(q.Resource.Long.Name())
|
||||
do := q.Resource.Where(q.Resource.UserID.Eq(authCtx.User.ID))
|
||||
if req.ResourceNo != nil && *req.ResourceNo != "" {
|
||||
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
|
||||
}
|
||||
if req.Active != nil {
|
||||
do = do.Where(q.Resource.Active.Is(*req.Active))
|
||||
}
|
||||
if req.ResourceType != nil {
|
||||
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
|
||||
}
|
||||
if req.Type != nil {
|
||||
if req.ResourceType != nil {
|
||||
switch m.ResourceType(*req.ResourceType) {
|
||||
case m.ResourceTypeShort:
|
||||
do = do.Where(short.Type.Eq(*req.Type))
|
||||
case m.ResourceTypeLong:
|
||||
do = do.Where(long.Type.Eq(*req.Type))
|
||||
}
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(*req.Type),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(*req.Type),
|
||||
))
|
||||
}
|
||||
}
|
||||
if req.CreateAfter != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Gte(req.CreateAfter.UTC()))
|
||||
}
|
||||
if req.CreateBefore != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Lte(req.CreateBefore.UTC()))
|
||||
}
|
||||
if req.ExpireAfter != nil {
|
||||
if req.ResourceType != nil {
|
||||
switch m.ResourceType(*req.ResourceType) {
|
||||
case m.ResourceTypeShort:
|
||||
do = do.Where(short.ExpireAt.Gte(req.ExpireAfter.UTC()))
|
||||
case m.ResourceTypeLong:
|
||||
do = do.Where(long.ExpireAt.Gte(req.ExpireAfter.UTC()))
|
||||
}
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.ExpireAt.Gte(req.ExpireAfter.UTC()),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.ExpireAt.Gte(req.ExpireAfter.UTC()),
|
||||
))
|
||||
}
|
||||
}
|
||||
if req.ExpireBefore != nil {
|
||||
if req.ResourceType != nil {
|
||||
switch m.ResourceType(*req.ResourceType) {
|
||||
case m.ResourceTypeShort:
|
||||
do = do.Where(short.ExpireAt.Lte(req.ExpireBefore.UTC()))
|
||||
case m.ResourceTypeLong:
|
||||
do = do.Where(long.ExpireAt.Lte(req.ExpireBefore.UTC()))
|
||||
}
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.ExpireAt.Lte(req.ExpireBefore.UTC()),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.ExpireAt.Lte(req.ExpireBefore.UTC()),
|
||||
))
|
||||
}
|
||||
}
|
||||
if req.Status != nil {
|
||||
now := time.Now().UTC()
|
||||
switch *req.Status {
|
||||
case 1:
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Gte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.GtCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Gte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.GtCol(long.Used),
|
||||
))
|
||||
case 2:
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.LteCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.LteCol(long.Used),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := q.Resource.Where(do).
|
||||
Joins(q.Resource.Short, q.Resource.Long).
|
||||
Order(q.Resource.CreatedAt.Desc()).
|
||||
Offset(req.GetOffset()).
|
||||
Limit(req.GetLimit()).
|
||||
Find()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var total int64
|
||||
if len(resource) < req.GetLimit() {
|
||||
total = int64(len(resource) + req.GetOffset())
|
||||
} else {
|
||||
total, err = q.Resource.
|
||||
Joins(q.Resource.Short, q.Resource.Long).
|
||||
Where(do).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(core.PageResp{
|
||||
Total: int(total),
|
||||
Page: req.GetPage(),
|
||||
Size: req.GetSize(),
|
||||
List: resource,
|
||||
})
|
||||
}
|
||||
|
||||
type PageResourceReq struct {
|
||||
core.PageReq
|
||||
ResourceNo *string `json:"resource_no"`
|
||||
Active *bool `json:"active"`
|
||||
ResourceType *int `json:"resource_type"` // 1 - 短效动态,2 - 长效动态
|
||||
Type *int `json:"type"` // 1 - 包时,2 - 包量
|
||||
CreateAfter *time.Time `json:"create_after"`
|
||||
CreateBefore *time.Time `json:"create_before"`
|
||||
ExpireAfter *time.Time `json:"expire_after"`
|
||||
ExpireBefore *time.Time `json:"expire_before"`
|
||||
Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期
|
||||
}
|
||||
|
||||
// PageResourceShort 分页查询当前用户短效套餐
|
||||
func PageResourceShort(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
@@ -209,6 +379,148 @@ type PageResourceLongReq struct {
|
||||
Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期
|
||||
}
|
||||
|
||||
// PageResourceByAdmin 分页查询全部长短效套餐
|
||||
func PageResourceByAdmin(c *fiber.Ctx) error {
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceRead)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req PageResourceByAdminReq
|
||||
if err = g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
short := q.ResourceShort.As("Short")
|
||||
long := q.ResourceLong.As("Long")
|
||||
do := q.Resource.Where()
|
||||
if req.UserPhone != nil {
|
||||
do = do.Where(q.User.As("User").Phone.Eq(*req.UserPhone))
|
||||
}
|
||||
if req.ResourceNo != nil {
|
||||
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
|
||||
}
|
||||
if req.Active != nil {
|
||||
do = do.Where(q.Resource.Active.Is(*req.Active))
|
||||
}
|
||||
if req.ResourceType != nil {
|
||||
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if req.ResourceType != nil {
|
||||
switch m.ResourceType(*req.ResourceType) {
|
||||
case m.ResourceTypeShort:
|
||||
do = do.Where(short.Type.Eq(*req.Mode))
|
||||
case m.ResourceTypeLong:
|
||||
do = do.Where(long.Type.Eq(*req.Mode))
|
||||
}
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(*req.Mode),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(*req.Mode),
|
||||
))
|
||||
}
|
||||
}
|
||||
if req.CreatedAtStart != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Gte(req.CreatedAtStart.UTC()))
|
||||
}
|
||||
if req.CreatedAtEnd != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
|
||||
}
|
||||
if req.Expired != nil {
|
||||
now := time.Now().UTC()
|
||||
if *req.Expired {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.LteCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.LteCol(long.Used),
|
||||
))
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Gt(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.GtCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Gt(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.GtCol(long.Used),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
list, total, err := q.Resource.
|
||||
Joins(q.Resource.User, q.Resource.Short, q.Resource.Long, q.Resource.Short.Sku, q.Resource.Long.Sku).
|
||||
Select(
|
||||
q.Resource.ALL,
|
||||
q.User.As("User").Phone.As("User__phone"),
|
||||
q.User.As("User").Name.As("User__name"),
|
||||
short.Type.As("Short__type"),
|
||||
short.Live.As("Short__live"),
|
||||
short.Quota.As("Short__quota"),
|
||||
short.Used.As("Short__used"),
|
||||
short.Daily.As("Short__daily"),
|
||||
short.LastAt.As("Short__last_at"),
|
||||
short.ExpireAt.As("Short__expire_at"),
|
||||
q.ProductSku.As("Short__Sku").Name.As("Short__Sku__name"),
|
||||
long.Type.As("Long__type"),
|
||||
long.Live.As("Long__live"),
|
||||
long.Quota.As("Long__quota"),
|
||||
long.Used.As("Long__used"),
|
||||
long.Daily.As("Long__daily"),
|
||||
long.LastAt.As("Long__last_at"),
|
||||
long.ExpireAt.As("Long__expire_at"),
|
||||
q.ProductSku.As("Long__Sku").Name.As("Long__Sku__name"),
|
||||
).
|
||||
Where(do).
|
||||
Order(q.Resource.CreatedAt.Desc()).
|
||||
FindByPage(req.GetOffset(), req.GetLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(core.PageResp{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
Page: req.GetPage(),
|
||||
Size: req.GetSize(),
|
||||
})
|
||||
}
|
||||
|
||||
type PageResourceByAdminReq struct {
|
||||
core.PageReq
|
||||
UserPhone *string `json:"user_phone" form:"user_phone"`
|
||||
ResourceNo *string `json:"resource_no" form:"resource_no"`
|
||||
Active *bool `json:"active" form:"active"`
|
||||
ResourceType *int `json:"resource_type" form:"resource_type"`
|
||||
Mode *int `json:"mode" form:"mode"`
|
||||
CreatedAtStart *time.Time `json:"created_at_start" form:"created_at_start"`
|
||||
CreatedAtEnd *time.Time `json:"created_at_end" form:"created_at_end"`
|
||||
Expired *bool `json:"expired" form:"expired"`
|
||||
}
|
||||
|
||||
// PageResourceShortByAdmin 分页查询全部短效套餐
|
||||
func PageResourceShortByAdmin(c *fiber.Ctx) error {
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead)
|
||||
@@ -393,6 +705,145 @@ type PageResourceLongByAdminReq struct {
|
||||
Expired *bool `json:"expired" form:"expired"`
|
||||
}
|
||||
|
||||
// PageResourceOfUserByAdmin 分页查询指定用户的长短效套餐
|
||||
func PageResourceOfUserByAdmin(c *fiber.Ctx) error {
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceReadOfUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req PageResourceOfUserByAdminReq
|
||||
if err = g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
short := q.ResourceShort.As("Short")
|
||||
long := q.ResourceLong.As("Long")
|
||||
do := q.Resource.Where(q.Resource.UserID.Eq(req.UserID))
|
||||
if req.ResourceNo != nil {
|
||||
do = do.Where(q.Resource.ResourceNo.Eq(*req.ResourceNo))
|
||||
}
|
||||
if req.Active != nil {
|
||||
do = do.Where(q.Resource.Active.Is(*req.Active))
|
||||
}
|
||||
if req.ResourceType != nil {
|
||||
do = do.Where(q.Resource.Type.Eq(*req.ResourceType))
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if req.ResourceType != nil {
|
||||
switch m.ResourceType(*req.ResourceType) {
|
||||
case m.ResourceTypeShort:
|
||||
do = do.Where(short.Type.Eq(*req.Mode))
|
||||
case m.ResourceTypeLong:
|
||||
do = do.Where(long.Type.Eq(*req.Mode))
|
||||
}
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(*req.Mode),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(*req.Mode),
|
||||
))
|
||||
}
|
||||
}
|
||||
if req.CreatedAtStart != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Gte(req.CreatedAtStart.UTC()))
|
||||
}
|
||||
if req.CreatedAtEnd != nil {
|
||||
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
|
||||
}
|
||||
if req.Expired != nil {
|
||||
now := time.Now().UTC()
|
||||
if *req.Expired {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.LteCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Lte(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.LteCol(long.Used),
|
||||
))
|
||||
} else {
|
||||
do = do.Where(q.Resource.Where(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeTime)),
|
||||
short.ExpireAt.Gt(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeShort)),
|
||||
short.Type.Eq(int(m.ResourceModeQuota)),
|
||||
short.Quota.GtCol(short.Used),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeTime)),
|
||||
long.ExpireAt.Gt(now),
|
||||
).Or(
|
||||
q.Resource.Type.Eq(int(m.ResourceTypeLong)),
|
||||
long.Type.Eq(int(m.ResourceModeQuota)),
|
||||
long.Quota.GtCol(long.Used),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
list, total, err := q.Resource.
|
||||
Joins(q.Resource.User, q.Resource.Short, q.Resource.Long, q.Resource.Short.Sku, q.Resource.Long.Sku).
|
||||
Select(
|
||||
q.Resource.ALL,
|
||||
q.User.As("User").Phone.As("User__phone"),
|
||||
q.User.As("User").Name.As("User__name"),
|
||||
short.Type.As("Short__type"),
|
||||
short.Live.As("Short__live"),
|
||||
short.Quota.As("Short__quota"),
|
||||
short.Used.As("Short__used"),
|
||||
short.Daily.As("Short__daily"),
|
||||
short.LastAt.As("Short__last_at"),
|
||||
short.ExpireAt.As("Short__expire_at"),
|
||||
q.ProductSku.As("Short__Sku").Name.As("Short__Sku__name"),
|
||||
long.Type.As("Long__type"),
|
||||
long.Live.As("Long__live"),
|
||||
long.Quota.As("Long__quota"),
|
||||
long.Used.As("Long__used"),
|
||||
long.Daily.As("Long__daily"),
|
||||
long.LastAt.As("Long__last_at"),
|
||||
long.ExpireAt.As("Long__expire_at"),
|
||||
q.ProductSku.As("Long__Sku").Name.As("Long__Sku__name"),
|
||||
).
|
||||
Where(do).
|
||||
Order(q.Resource.CreatedAt.Desc()).
|
||||
FindByPage(req.GetOffset(), req.GetLimit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(core.PageResp{
|
||||
List: list,
|
||||
Total: int(total),
|
||||
Page: req.GetPage(),
|
||||
Size: req.GetSize(),
|
||||
})
|
||||
}
|
||||
|
||||
type PageResourceOfUserByAdminReq struct {
|
||||
core.PageReq
|
||||
UserID int32 `json:"user_id" validate:"required"`
|
||||
ResourceNo *string `json:"resource_no"`
|
||||
Active *bool `json:"active"`
|
||||
ResourceType *int `json:"resource_type"`
|
||||
Mode *int `json:"mode"`
|
||||
CreatedAtStart *time.Time `json:"created_at_start"`
|
||||
CreatedAtEnd *time.Time `json:"created_at_end"`
|
||||
Expired *bool `json:"expired"`
|
||||
}
|
||||
|
||||
// PageResourceShortOfUserByAdmin 分页查询指定用户的短效套餐
|
||||
func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser)
|
||||
@@ -422,7 +873,7 @@ func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
|
||||
do = do.Where(q.Resource.CreatedAt.Lte(req.CreatedAtEnd.UTC()))
|
||||
}
|
||||
|
||||
list, total, err := q.Resource.Debug().
|
||||
list, total, err := q.Resource.
|
||||
Joins(q.Resource.User, q.Resource.Short, q.Resource.Short.Sku).
|
||||
Select(
|
||||
q.Resource.ALL,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"platform/pkg/env"
|
||||
"platform/web/auth"
|
||||
"platform/web/core"
|
||||
g "platform/web/globals"
|
||||
@@ -14,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// PageTradeByAdmin 分页查询所有订单
|
||||
@@ -221,6 +219,36 @@ type TradeCreateReq struct {
|
||||
|
||||
// ============================================================
|
||||
|
||||
// 更新订单备注
|
||||
func TradeUpdateRemarkByAdmin(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析请求参数
|
||||
var req TradeUpdateRemarkReq
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新订单备注
|
||||
err = s.Trade.UpdateRemark(req.TradeNo, req.Remark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
type TradeUpdateRemarkReq struct {
|
||||
TradeNo string `json:"trade_no" validate:"required"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
// 完成订单
|
||||
func TradeComplete(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
@@ -276,6 +304,29 @@ func TradeCompleteByAdmin(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// 订单补余额
|
||||
func TradeConvertByAdmin(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeTradeWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析请求参数
|
||||
var req s.TradeRef
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 订单补余额
|
||||
err = s.Trade.ConvertTradeToBalance(authCtx.Admin, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
// 取消订单
|
||||
@@ -287,14 +338,14 @@ func TradeCancel(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 解析请求参数
|
||||
req := new(TradeCancelReq)
|
||||
req := new(s.TradeRef)
|
||||
if err := g.Validator.ParseBody(c, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 取消交易
|
||||
err = s.Trade.CancelTrade(&req.TradeRef)
|
||||
if err != nil {
|
||||
err = s.Trade.CancelTrade(req)
|
||||
if err != nil && err != s.ErrTradeStatusIgnored {
|
||||
slog.Error("取消交易失败", "trade_no", req.TradeNo, "error", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "取消交易失败"})
|
||||
}
|
||||
@@ -302,62 +353,177 @@ func TradeCancel(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
type TradeCancelReq struct {
|
||||
s.TradeRef
|
||||
// 管理员取消订单
|
||||
func TradeCancelByAdmin(c *fiber.Ctx) error {
|
||||
// 解析请求参数
|
||||
req := new(s.TradeRef)
|
||||
if err := g.Validator.ParseBody(c, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 取消交易
|
||||
err := s.Trade.CancelTrade(req)
|
||||
if err != nil && err != s.ErrTradeStatusIgnored {
|
||||
slog.Error("取消交易失败", "trade_no", req.TradeNo, "error", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "取消交易失败"})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
// 结束订单:完成或取消订单
|
||||
func TradeFinish(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
authCtx, err := auth.GetAuthCtx(c).PermitUser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析请求参数
|
||||
var req s.TradeRef
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 尝试取消交易
|
||||
err = s.Trade.CancelTrade(&req)
|
||||
if err == s.ErrTradeStatusIgnored {
|
||||
// 尝试完成交易
|
||||
err = s.Trade.CompleteTrade(authCtx.User, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(map[string]m.TradeStatus{
|
||||
"status": m.TradeStatusSuccess,
|
||||
})
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(map[string]m.TradeStatus{
|
||||
"status": m.TradeStatusCanceled,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
// 检查订单
|
||||
func TradeCheck(c *fiber.Ctx) error {
|
||||
// 检查权限:sse 接口暂时不检查权限
|
||||
|
||||
// 解析请求参数
|
||||
req := new(TradeCheckReq)
|
||||
if err := g.Validator.ParseQuery(c, req); err != nil {
|
||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeTradeRead)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Set(fiber.HeaderContentType, "text/event-stream")
|
||||
c.Set(fiber.HeaderCacheControl, "no-cache")
|
||||
c.Set(fiber.HeaderConnection, "keep-alive")
|
||||
c.Set(fiber.HeaderTransferEncoding, "chunked")
|
||||
c.Context().SetBodyStreamWriter(fasthttp.StreamWriter(func(w *bufio.Writer) {
|
||||
// 解析请求参数
|
||||
var req s.TradeRef
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expire := env.TradeExpire
|
||||
interval := 5
|
||||
for range expire / interval {
|
||||
// 检查订单状态
|
||||
result, err := s.Trade.CheckTrade(&req.TradeRef)
|
||||
if err != nil {
|
||||
slog.Error("检查订单状态失败", "trade_no", req.TradeNo, "error", err)
|
||||
return
|
||||
}
|
||||
// 检查订单状态
|
||||
result, err := s.Trade.CheckTrade(&req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 写入订单状态
|
||||
_, err = fmt.Fprintf(w, "data: %d\n\n", result.Status)
|
||||
if err != nil {
|
||||
slog.Error("写入订单状态失败", "trade_no", req.TradeNo, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = w.Flush()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 当订单离开支付状态后结束查询
|
||||
if result.Status != m.TradeStatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(interval) * time.Second)
|
||||
}
|
||||
}))
|
||||
|
||||
return nil
|
||||
return c.JSON(result)
|
||||
}
|
||||
|
||||
type TradeCheckReq struct {
|
||||
s.TradeRef
|
||||
// ============================================================
|
||||
|
||||
// 交易回调
|
||||
func TradeCallbackSft(c *fiber.Ctx) error {
|
||||
|
||||
// 解析请求参数
|
||||
var req TradeCallbackSftReq
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return core.NewServErr("解析请求失败", err)
|
||||
}
|
||||
|
||||
var bizData TradeCallbackSftReqBizData
|
||||
if err := json.Unmarshal([]byte(*req.BizData), &bizData); err != nil {
|
||||
return core.NewServErr("解析业务数据失败", err)
|
||||
}
|
||||
|
||||
// 检查通知
|
||||
if bizData.MsgType != string(g.SftNotifyTypeTrade) {
|
||||
return core.NewBizErr("消息类型处理异常")
|
||||
}
|
||||
|
||||
if bizData.State != string(g.SftTradeSuccess) {
|
||||
return core.NewBizErr("订单状态处理异常")
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
|
||||
if bizData.PayType == nil {
|
||||
return core.NewBizErr("支付渠道解析错误")
|
||||
}
|
||||
|
||||
var acquirer m.TradeAcquirer
|
||||
switch *bizData.PayType {
|
||||
case string(g.SftAlipay):
|
||||
acquirer = m.TradeAcquirerAlipay
|
||||
case string(g.SftWeChat):
|
||||
acquirer = m.TradeAcquirerWechat
|
||||
case string(g.SftUnionPay):
|
||||
acquirer = m.TradeAcquirerUnionPay
|
||||
default:
|
||||
return core.NewBizErr("支付渠道类型异常")
|
||||
}
|
||||
|
||||
var time, err = time.Parse("2006-01-02 15:04:05", *bizData.PayTime)
|
||||
if err != nil {
|
||||
return core.NewBizErr("时间解析错误")
|
||||
}
|
||||
|
||||
err = s.Trade.CompleteTradeByNotify(bizData.MchOrderNo, bizData.PayOrderId, &s.TradeSuccessResult{
|
||||
Acquirer: acquirer,
|
||||
Actual: decimal.NewFromInt(bizData.Amount).Div(decimal.NewFromInt(100)),
|
||||
Time: time,
|
||||
})
|
||||
if err != nil {
|
||||
return core.NewServErr("完成交易失败", err)
|
||||
}
|
||||
|
||||
return c.Send([]byte("SUCCESS"))
|
||||
}
|
||||
|
||||
type TradeCallbackSftReq struct {
|
||||
Code string `json:"code"`
|
||||
Msg *string `json:"msg"`
|
||||
Sign *string `json:"sign"`
|
||||
BizData *string `json:"bizData"`
|
||||
SignType *string `json:"signType"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type TradeCallbackSftReqBizData struct {
|
||||
MsgType string `json:"msgType"`
|
||||
PayOrderId string `json:"payOrderId"`
|
||||
State string `json:"state"`
|
||||
MercNo string `json:"mercNo"`
|
||||
StoreId string `json:"storeId"`
|
||||
MchOrderNo string `json:"mchOrderNo"`
|
||||
IfCode string `json:"ifCode"`
|
||||
Amount int64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
ChannelTradeNo *string `json:"channelTradeNo"`
|
||||
ChannelSendNo *string `json:"channelSendNo"`
|
||||
UserId *string `json:"userId"`
|
||||
ExtParam *string `json:"extParam"`
|
||||
PayTime *string `json:"payTime"`
|
||||
Note *string `json:"note"`
|
||||
PayType *string `json:"payType"`
|
||||
DrType *string `json:"drType"`
|
||||
TradeFee *int64 `json:"tradeFee"`
|
||||
CashFee *int64 `json:"cashFee"`
|
||||
RefundAmt *int64 `json:"refundAmt"`
|
||||
RefundState *int32 `json:"refundState"`
|
||||
SettlementType *string `json:"settlementType"`
|
||||
}
|
||||
|
||||
@@ -234,38 +234,6 @@ func RemoveUserByAdmin(c *fiber.Ctx) error {
|
||||
return c.JSON(nil)
|
||||
}
|
||||
|
||||
// 管理员更新用户余额
|
||||
func UpdateUserBalanceByAdmin(c *fiber.Ctx) error {
|
||||
authCtx, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeUserWriteBalance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req UpdateUserBalanceByAdminData
|
||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := s.User.Get(q.Q, req.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
balance, err := decimal.NewFromString(req.Balance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.User.UpdateBalanceByAdmin(user, balance, &authCtx.Admin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(nil)
|
||||
}
|
||||
|
||||
type UpdateUserBalanceByAdminData struct {
|
||||
UserID int32 `json:"user_id" validate:"required"`
|
||||
Balance string `json:"balance" validate:"required"`
|
||||
}
|
||||
|
||||
// 绑定管理员
|
||||
func BindAdmin(c *fiber.Ctx) error {
|
||||
// 检查权限
|
||||
@@ -514,7 +482,7 @@ func UpdateUserBalanceIncByAdmin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
newBalance := user.Balance.Add(amount)
|
||||
if err := s.User.UpdateBalanceByAdmin(user, newBalance, &authCtx.Admin.ID); err != nil {
|
||||
if err := s.User.UpdateBalanceByAdmin(user, newBalance, req.Remark, &authCtx.Admin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -547,7 +515,7 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
newBalance := user.Balance.Sub(amount)
|
||||
if err := s.User.UpdateBalanceByAdmin(user, newBalance, &authCtx.Admin.ID); err != nil {
|
||||
if err := s.User.UpdateBalanceByAdmin(user, newBalance, req.Remark, &authCtx.Admin.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -557,4 +525,5 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
|
||||
type UpdateUserBalanceChangeByAdminData struct {
|
||||
UserID int32 `json:"user_id" validate:"required"`
|
||||
Amount string `json:"amount" validate:"required"`
|
||||
Remark string `json:"remark" validate:"required"`
|
||||
}
|
||||
|
||||
@@ -25,11 +25,12 @@ func ApplyMiddlewares(app *fiber.App) {
|
||||
EnableStackTrace: true,
|
||||
}))
|
||||
|
||||
// metric
|
||||
// otel
|
||||
app.Use(otelfiber.Middleware())
|
||||
|
||||
// logger
|
||||
app.Use(logger.New(logger.Config{
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
return c.Path() == "/favicon.ico"
|
||||
},
|
||||
@@ -44,12 +45,23 @@ func ApplyMiddlewares(app *fiber.App) {
|
||||
return err
|
||||
}
|
||||
|
||||
str := ""
|
||||
if err != nil {
|
||||
str = err.Error()
|
||||
span.SetAttributes(attribute.String("http.response.error", err.Error()))
|
||||
}
|
||||
|
||||
authCtx := auth.GetAuthCtx(c)
|
||||
if authCtx != nil {
|
||||
if authCtx.User != nil {
|
||||
span.SetAttributes(attribute.Int("auth.user.id", int(authCtx.User.ID)))
|
||||
}
|
||||
if authCtx.Admin != nil {
|
||||
span.SetAttributes(attribute.Int("auth.admin.id", int(authCtx.Admin.ID)))
|
||||
}
|
||||
if authCtx.Client != nil {
|
||||
span.SetAttributes(attribute.Int("auth.client.id", int(authCtx.Client.ID)))
|
||||
}
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("http.response.error", str))
|
||||
return err
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import "platform/web/core"
|
||||
type Article struct {
|
||||
core.Model
|
||||
GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID
|
||||
Code *string `json:"code,omitempty" gorm:"column:code"` // 文章编码,用于固定引用链接
|
||||
Title string `json:"title" gorm:"column:title"` // 文章标题
|
||||
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
|
||||
Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序
|
||||
|
||||
@@ -32,6 +32,7 @@ func newArticle(db *gorm.DB, opts ...gen.DOOption) article {
|
||||
_article.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||
_article.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||
_article.GroupID = field.NewInt32(tableName, "group_id")
|
||||
_article.Code = field.NewString(tableName, "code")
|
||||
_article.Title = field.NewString(tableName, "title")
|
||||
_article.Content = field.NewString(tableName, "content")
|
||||
_article.Sort = field.NewInt32(tableName, "sort")
|
||||
@@ -56,6 +57,7 @@ type article struct {
|
||||
UpdatedAt field.Time
|
||||
DeletedAt field.Field
|
||||
GroupID field.Int32
|
||||
Code field.String
|
||||
Title field.String
|
||||
Content field.String
|
||||
Sort field.Int32
|
||||
@@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article {
|
||||
a.UpdatedAt = field.NewTime(table, "updated_at")
|
||||
a.DeletedAt = field.NewField(table, "deleted_at")
|
||||
a.GroupID = field.NewInt32(table, "group_id")
|
||||
a.Code = field.NewString(table, "code")
|
||||
a.Title = field.NewString(table, "title")
|
||||
a.Content = field.NewString(table, "content")
|
||||
a.Sort = field.NewInt32(table, "sort")
|
||||
@@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
||||
}
|
||||
|
||||
func (a *article) fillFieldMap() {
|
||||
a.fieldMap = make(map[string]field.Expr, 10)
|
||||
a.fieldMap = make(map[string]field.Expr, 11)
|
||||
a.fieldMap["id"] = a.ID
|
||||
a.fieldMap["created_at"] = a.CreatedAt
|
||||
a.fieldMap["updated_at"] = a.UpdatedAt
|
||||
a.fieldMap["deleted_at"] = a.DeletedAt
|
||||
a.fieldMap["group_id"] = a.GroupID
|
||||
a.fieldMap["code"] = a.Code
|
||||
a.fieldMap["title"] = a.Title
|
||||
a.fieldMap["content"] = a.Content
|
||||
a.fieldMap["sort"] = a.Sort
|
||||
|
||||
@@ -23,6 +23,7 @@ func ApplyRouters(app *fiber.App) {
|
||||
// 回调
|
||||
callbacks := app.Group("/callback")
|
||||
callbacks.Get("/identify", handlers.IdentifyCallbackNew)
|
||||
callbacks.All("/trade/sft", handlers.TradeCallbackSft)
|
||||
|
||||
// 临时
|
||||
if env.RunMode == env.RunModeDev {
|
||||
@@ -66,10 +67,6 @@ func publicRouter(api fiber.Router) {
|
||||
resource := api.Group("/resource")
|
||||
resource.Post("/price", handlers.ResourcePrice)
|
||||
|
||||
// 交易
|
||||
trade := api.Group("/trade")
|
||||
trade.Get("/check", handlers.TradeCheck)
|
||||
|
||||
// 前台
|
||||
inquiry := api.Group("/inquiry")
|
||||
inquiry.Post("/create", handlers.CreateInquiry)
|
||||
@@ -116,6 +113,7 @@ func userRouter(api fiber.Router) {
|
||||
// 套餐
|
||||
resource := api.Group("/resource")
|
||||
resource.Post("/all", handlers.AllActiveResource)
|
||||
resource.Post("/list", handlers.PageResource)
|
||||
resource.Post("/list/short", handlers.PageResourceShort)
|
||||
resource.Post("/list/long", handlers.PageResourceLong)
|
||||
resource.Post("/create", handlers.CreateResource)
|
||||
@@ -144,6 +142,7 @@ func userRouter(api fiber.Router) {
|
||||
trade.Post("/create", handlers.TradeCreate)
|
||||
trade.Post("/complete", handlers.TradeComplete)
|
||||
trade.Post("/cancel", handlers.TradeCancel)
|
||||
trade.Post("/finish", handlers.TradeFinish)
|
||||
|
||||
// 账单
|
||||
bill := api.Group("/bill")
|
||||
@@ -171,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)
|
||||
@@ -202,12 +207,13 @@ func adminRouter(api fiber.Router) {
|
||||
user.Post("/remove", handlers.RemoveUserByAdmin)
|
||||
|
||||
user.Post("/update/bind", handlers.BindAdmin)
|
||||
user.Post("/update/balance", handlers.UpdateUserBalanceByAdmin)
|
||||
user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin)
|
||||
user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin)
|
||||
|
||||
// resource 套餐
|
||||
var resource = api.Group("/resource")
|
||||
resource.Post("/page", handlers.PageResourceByAdmin)
|
||||
resource.Post("/page/of-user", handlers.PageResourceOfUserByAdmin)
|
||||
resource.Post("/short/page", handlers.PageResourceShortByAdmin)
|
||||
resource.Post("/short/page/of-user", handlers.PageResourceShortOfUserByAdmin)
|
||||
resource.Post("/long/page", handlers.PageResourceLongByAdmin)
|
||||
@@ -241,6 +247,10 @@ func adminRouter(api fiber.Router) {
|
||||
trade.Post("/page", handlers.PageTradeByAdmin)
|
||||
trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin)
|
||||
trade.Post("/complete", handlers.TradeCompleteByAdmin)
|
||||
trade.Post("/convert", handlers.TradeConvertByAdmin)
|
||||
trade.Post("/cancel", handlers.TradeCancelByAdmin)
|
||||
trade.Post("/update/remark", handlers.TradeUpdateRemarkByAdmin)
|
||||
trade.Post("/check", handlers.TradeCheck)
|
||||
|
||||
// bill 账单
|
||||
var bill = api.Group("/bill")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"platform/web/core"
|
||||
m "platform/web/models"
|
||||
q "platform/web/queries"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||
|
||||
type ArticleUploadResult struct {
|
||||
URL string `json:"url"`
|
||||
Path string `json:"path"`
|
||||
@@ -151,7 +154,13 @@ func formatUploadSizeLimit(bytes int) string {
|
||||
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
|
||||
do := q.Article.Where()
|
||||
if req.Keyword != nil && *req.Keyword != "" {
|
||||
do = do.Where(q.Article.Title.Like("%" + *req.Keyword + "%"))
|
||||
do = do.Where(
|
||||
q.Article.Where(
|
||||
q.Article.Title.Like("%" + *req.Keyword + "%"),
|
||||
).Or(
|
||||
q.Article.Code.Like("%" + *req.Keyword + "%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
if req.GroupID != nil {
|
||||
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
|
||||
@@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.Article.Create(&m.Article{
|
||||
code, err := optionalArticleCode(data.Code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = q.Article.Create(&m.Article{
|
||||
GroupID: data.GroupID,
|
||||
Code: code,
|
||||
Title: data.Title,
|
||||
Content: data.Content,
|
||||
Sort: u.Else(data.Sort, 0),
|
||||
Status: u.Else(data.Status, m.ArticleStatusEnabled),
|
||||
})
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return core.NewBizErr("文档编码已存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type CreateArticleData struct {
|
||||
GroupID int32 `json:"group_id" validate:"required"`
|
||||
Code *string `json:"code"`
|
||||
Title string `json:"title" validate:"required"`
|
||||
Content *string `json:"content"`
|
||||
Sort *int32 `json:"sort"`
|
||||
@@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error {
|
||||
if data.GroupID != nil {
|
||||
do = append(do, q.Article.GroupID.Value(*data.GroupID))
|
||||
}
|
||||
if data.Code != nil {
|
||||
code, err := normalizeArticleCode(*data.Code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
do = append(do, q.Article.Code.Value(code))
|
||||
}
|
||||
if data.Title != nil {
|
||||
do = append(do, q.Article.Title.Value(*data.Title))
|
||||
}
|
||||
@@ -239,6 +266,9 @@ func (s *articleService) Update(data UpdateArticleData) error {
|
||||
}
|
||||
|
||||
r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...)
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return core.NewBizErr("文档编码已存在")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error {
|
||||
type UpdateArticleData struct {
|
||||
ID int32 `json:"id" validate:"required"`
|
||||
GroupID *int32 `json:"group_id"`
|
||||
Code *string `json:"code"`
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Sort *int32 `json:"sort"`
|
||||
@@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
|
||||
}
|
||||
group.Articles = append(group.Articles, &ArticleNavArticle{
|
||||
ID: article.ID,
|
||||
Code: article.Code,
|
||||
Title: article.Title,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
})
|
||||
@@ -331,18 +363,40 @@ type ArticleNavGroup struct {
|
||||
|
||||
type ArticleNavArticle struct {
|
||||
ID int32 `json:"id"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
||||
article, err := q.Article.
|
||||
type GetArticleReq struct {
|
||||
ID *int32 `json:"id"`
|
||||
Code *string `json:"code"`
|
||||
}
|
||||
|
||||
func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) {
|
||||
do := q.Article.
|
||||
Preload(q.Article.Group).
|
||||
Where(
|
||||
q.Article.ID.Eq(id),
|
||||
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
|
||||
).
|
||||
Take()
|
||||
Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled)))
|
||||
|
||||
switch {
|
||||
case req.ID != nil && req.Code != nil:
|
||||
return nil, core.NewBizErr("文档 ID 和编码不能同时提供")
|
||||
case req.ID != nil:
|
||||
if *req.ID <= 0 {
|
||||
return nil, core.NewBizErr("文档 ID 不正确")
|
||||
}
|
||||
do = do.Where(q.Article.ID.Eq(*req.ID))
|
||||
case req.Code != nil:
|
||||
code, err := normalizeArticleCode(*req.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
do = do.Where(q.Article.Code.Eq(code))
|
||||
default:
|
||||
return nil, core.NewBizErr("必须提供文档 ID 或编码")
|
||||
}
|
||||
|
||||
article, err := do.Take()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, core.NewBizErr("文档不存在")
|
||||
}
|
||||
@@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
||||
|
||||
return &ArticlePublicDetail{
|
||||
ID: article.ID,
|
||||
Code: article.Code,
|
||||
Title: article.Title,
|
||||
Content: article.Content,
|
||||
UpdatedAt: article.UpdatedAt,
|
||||
@@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
||||
|
||||
type ArticlePublicDetail struct {
|
||||
ID int32 `json:"id"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func optionalArticleCode(code *string) (*string, error) {
|
||||
if code == nil || strings.TrimSpace(*code) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
normalized, err := normalizeArticleCode(*code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func normalizeArticleCode(code string) (string, error) {
|
||||
normalized := strings.TrimSpace(code)
|
||||
if !articleCodePattern.MatchString(normalized) {
|
||||
return "", core.NewBizErr("文档编码只能包含小写字母、数字、下划线或连字符,长度为 1-64 位")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
@@ -136,7 +136,12 @@ func (s *channelServer) RemoveChannels(batch string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.provider.removeRemote(batch, usedBatch); err != nil {
|
||||
provider, err := channelProviderByProxyID(usedBatch.ProxyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.removeRemote(batch, usedBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := freeChans(usedBatch.ProxyID, batch); err != nil {
|
||||
@@ -407,6 +412,22 @@ func selectProxyByType(proxyType m.ProxyType, count int) (*m.Proxy, error) {
|
||||
return bestProxy, nil
|
||||
}
|
||||
|
||||
func channelProviderByProxyID(proxyID int32) (channelProvider, error) {
|
||||
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(proxyID)).Take()
|
||||
if err != nil {
|
||||
return nil, core.NewServErr("获取代理数据失败", err)
|
||||
}
|
||||
|
||||
switch proxy.Type {
|
||||
case m.ProxyTypeGost:
|
||||
return &channelGostProvider{}, nil
|
||||
case m.ProxyTypeBaiYin:
|
||||
return &channelBaiyinProvider{}, nil
|
||||
default:
|
||||
return nil, core.NewBizErr(fmt.Sprintf("不支持的代理类型: %d", proxy.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *channelServer) RefreshEdges() error {
|
||||
|
||||
// 仅白银网关支持边缘节点刷新,GOST 不参与此流程。
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"platform/web/core"
|
||||
g "platform/web/globals"
|
||||
m "platform/web/models"
|
||||
q "platform/web/queries"
|
||||
)
|
||||
|
||||
type channelBaiyinProvider struct{}
|
||||
@@ -68,30 +67,31 @@ func (s *channelBaiyinProvider) prepareCreate(ctx *channelCreateContext) (*chann
|
||||
}
|
||||
|
||||
func (s *channelBaiyinProvider) removeRemote(_ string, batch *usedChanBatch) error {
|
||||
configs := make([]*g.PortConfigsReq, len(batch.Chans))
|
||||
for i, ch := range batch.Chans {
|
||||
configs[i] = &g.PortConfigsReq{
|
||||
Port: int(ch.Port()),
|
||||
Edge: &[]string{},
|
||||
AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
|
||||
Status: false,
|
||||
}
|
||||
}
|
||||
|
||||
proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
|
||||
if err != nil {
|
||||
return core.NewServErr("获取代理数据失败", err)
|
||||
}
|
||||
|
||||
gateway, err := proxyGateway(proxy)
|
||||
if err != nil {
|
||||
return core.NewServErr("创建代理网关失败", err)
|
||||
}
|
||||
|
||||
if err = gateway.GatewayPortConfigs(configs); err != nil {
|
||||
return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
|
||||
}
|
||||
return nil
|
||||
// configs := make([]*g.PortConfigsReq, len(batch.Chans))
|
||||
// for i, ch := range batch.Chans {
|
||||
// configs[i] = &g.PortConfigsReq{
|
||||
// Port: int(ch.Port()),
|
||||
// Edge: &[]string{},
|
||||
// AutoEdgeConfig: &g.AutoEdgeConfig{Count: u.P(0)},
|
||||
// Status: false,
|
||||
// }
|
||||
// }
|
||||
|
||||
// proxy, err := q.Proxy.Where(q.Proxy.ID.Eq(batch.ProxyID)).Take()
|
||||
// if err != nil {
|
||||
// return core.NewServErr("获取代理数据失败", err)
|
||||
// }
|
||||
|
||||
// gateway, err := proxyGateway(proxy)
|
||||
// if err != nil {
|
||||
// return core.NewServErr("创建代理网关失败", err)
|
||||
// }
|
||||
|
||||
// if err = gateway.GatewayPortConfigs(configs); err != nil {
|
||||
// return core.NewServErr(fmt.Sprintf("清空代理 %s 端口配置失败", proxy.IP.String()), err)
|
||||
// }
|
||||
// return nil
|
||||
}
|
||||
|
||||
// ensureEdges 检查本地节点是否足够,如果不足从云端连入
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
m "platform/web/models"
|
||||
q "platform/web/queries"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gen/field"
|
||||
)
|
||||
@@ -51,8 +52,10 @@ func (s *channelGostProvider) prepareCreate(ctx *channelCreateContext) (*channel
|
||||
Type: "tcp",
|
||||
},
|
||||
Recorders: []g.GostRecorderConfig{
|
||||
{Name: "record-file", Record: "recorder.service.handler"},
|
||||
{Name: "record-http-otel", Record: "recorder.service.handler"},
|
||||
},
|
||||
Limiter: "limiter-8m",
|
||||
Bypasses: []string{"china", "deny"},
|
||||
}
|
||||
|
||||
if ctx.AuthWhitelist {
|
||||
@@ -129,7 +132,12 @@ func (s *channelGostProvider) removeRemote(batchNo string, batch *usedChanBatch)
|
||||
return client.DeleteAdmission(admissionName)
|
||||
}))
|
||||
}
|
||||
return u.CombineErrors(deleteErrs)
|
||||
if len(deleteErrs) != 0 {
|
||||
return u.CombineErrors(deleteErrs)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *channelGostProvider) selectProxy(count int) (*m.Proxy, error) {
|
||||
|
||||
424
web/services/home.go
Normal file
424
web/services/home.go
Normal file
@@ -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
|
||||
}
|
||||
87
web/services/home_test.go
Normal file
87
web/services/home_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
q "platform/web/queries"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gen/field"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ func (s *productService) AllProducts() ([]*m.Product, error) {
|
||||
Find()
|
||||
}
|
||||
|
||||
func (s *productService) AllProductSaleInfos() ([]*m.Product, error) {
|
||||
func (s *productService) AllProductSaleInfos() ([]any, error) {
|
||||
products, err := q.Product.
|
||||
Select(
|
||||
q.Product.ID,
|
||||
@@ -43,7 +44,17 @@ func (s *productService) AllProductSaleInfos() ([]*m.Product, error) {
|
||||
pids[i] = p.ID
|
||||
}
|
||||
|
||||
skus, err := q.ProductSku.
|
||||
type SkuInfo struct {
|
||||
ID int32 `json:"id"`
|
||||
ProductID int32 `json:"product_id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Price decimal.Decimal `json:"price"`
|
||||
CountMin decimal.Decimal `json:"count_min"`
|
||||
Discount float64 `json:"discount"`
|
||||
}
|
||||
var skus []*SkuInfo
|
||||
err = q.ProductSku.
|
||||
Select(
|
||||
q.ProductSku.ID,
|
||||
q.ProductSku.ProductID,
|
||||
@@ -51,29 +62,47 @@ func (s *productService) AllProductSaleInfos() ([]*m.Product, error) {
|
||||
q.ProductSku.Code,
|
||||
q.ProductSku.Price,
|
||||
q.ProductSku.CountMin,
|
||||
q.ProductDiscount.Discount,
|
||||
).
|
||||
Where(
|
||||
q.ProductSku.ProductID.In(pids...),
|
||||
q.ProductSku.Status.Eq(int32(m.SkuStatusEnabled)),
|
||||
).
|
||||
LeftJoin(q.ProductDiscount, q.ProductDiscount.ID.EqCol(q.ProductSku.DiscountId)).
|
||||
Order(q.ProductSku.Sort).
|
||||
Find()
|
||||
Scan(&skus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pmap := make(map[int32]*m.Product, len(products))
|
||||
type ProductInfo struct {
|
||||
m.Product
|
||||
Skus []*SkuInfo `json:"skus,omitempty"`
|
||||
}
|
||||
pmap := make(map[int32]*ProductInfo, len(products))
|
||||
for _, p := range products {
|
||||
pmap[p.ID] = p
|
||||
p.Skus = make([]*m.ProductSku, 0)
|
||||
pmap[p.ID] = &ProductInfo{Product: *p, Skus: make([]*SkuInfo, 0)}
|
||||
}
|
||||
for _, s := range skus {
|
||||
if p, ok := pmap[s.ProductID]; ok {
|
||||
p.Skus = append(p.Skus, s)
|
||||
p.Skus = append(p.Skus, &SkuInfo{
|
||||
ID: s.ID,
|
||||
ProductID: s.ProductID,
|
||||
Name: s.Name,
|
||||
Code: s.Code,
|
||||
Price: s.Price,
|
||||
CountMin: s.CountMin,
|
||||
Discount: s.Discount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return products, nil
|
||||
plist := make([]any, 0, len(pmap))
|
||||
for _, p := range pmap {
|
||||
plist = append(plist, p)
|
||||
}
|
||||
|
||||
return plist, nil
|
||||
}
|
||||
|
||||
// 新增产品
|
||||
|
||||
@@ -46,8 +46,8 @@ func rebuildFreeChans(proxyID int32, addr netip.Addr) error {
|
||||
return err
|
||||
}
|
||||
|
||||
chans := make([]netip.AddrPort, 10000)
|
||||
for i := range 10000 {
|
||||
chans := make([]netip.AddrPort, 30000)
|
||||
for i := range 30000 {
|
||||
chans[i] = netip.AddrPortFrom(addr, uint16(i+10000))
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productD
|
||||
Currency: "cny",
|
||||
ClientIp: "123.52.74.23",
|
||||
OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")),
|
||||
NotifyUrl: &env.SftNotifyUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -204,12 +205,7 @@ func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productD
|
||||
// 缓存产品数据
|
||||
w := bytes.Buffer{}
|
||||
gob.NewEncoder(&w).Encode(detail)
|
||||
err = g.Redis.Set(
|
||||
context.Background(),
|
||||
tradeProductKey(tradeNo),
|
||||
w.Bytes(),
|
||||
expireIn,
|
||||
).Err()
|
||||
err = g.Redis.Set(context.Background(), tradeProductKey(tradeNo), w.Bytes(), 0).Err()
|
||||
if err != nil {
|
||||
return nil, core.NewServErr("保存购买信息失败", err)
|
||||
}
|
||||
@@ -243,23 +239,47 @@ func (s *tradeService) CompleteTrade(user *m.User, ref *TradeRef) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交易信息
|
||||
trade, err := q.Trade.
|
||||
Preload(q.Trade.User).
|
||||
Where(q.Trade.InnerNo.Eq(ref.TradeNo)).
|
||||
Take()
|
||||
if err != nil {
|
||||
return core.NewBizErr("获取交易信息失败", err)
|
||||
}
|
||||
if trade.User == nil || trade.User.ID != user.ID {
|
||||
return core.NewBizErr("交易信息不存在")
|
||||
}
|
||||
|
||||
// 更新交易状态
|
||||
err = s.OnCompleteTrade(user, ref.TradeNo, result.TransId, &result.Success)
|
||||
err = s.OnCompleteTrade(trade, ref.TradeNo, result.TransId, &result.Success)
|
||||
if err != nil {
|
||||
return core.NewServErr("处理交易失败", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo string, result *TradeSuccessResult) error {
|
||||
|
||||
// 获取交易信息
|
||||
func (s *tradeService) CompleteTradeByNotify(interNo string, outerNo string, result *TradeSuccessResult) error {
|
||||
trade, err := q.Trade.
|
||||
Preload(q.Trade.User).
|
||||
Where(q.Trade.InnerNo.Eq(interNo)).
|
||||
Take()
|
||||
if err != nil {
|
||||
return core.NewBizErr("获取交易信息失败", err)
|
||||
}
|
||||
if trade.User == nil {
|
||||
return core.NewBizErr("交易信息不存在")
|
||||
}
|
||||
|
||||
err = s.OnCompleteTrade(trade, interNo, outerNo, result)
|
||||
if err != nil {
|
||||
return core.NewServErr("处理交易失败", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func (s *tradeService) OnCompleteTrade(trade *m.Trade, interNo string, outerNo string, result *TradeSuccessResult) error {
|
||||
user := trade.User
|
||||
|
||||
// 检查交易状态
|
||||
switch trade.Status {
|
||||
@@ -271,7 +291,8 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
|
||||
}
|
||||
|
||||
// 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型
|
||||
detailBytes, err := g.Redis.Get(context.Background(), tradeProductKey(interNo)).Bytes()
|
||||
tradeKey := tradeProductKey(interNo)
|
||||
detailBytes, err := g.Redis.Get(context.Background(), tradeKey).Bytes()
|
||||
if err != nil {
|
||||
return core.NewServErr("恢复购买信息失败", err)
|
||||
}
|
||||
@@ -344,6 +365,12 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
|
||||
}
|
||||
}
|
||||
|
||||
// 删除缓存
|
||||
err = g.Redis.Del(context.Background(), tradeKey).Err()
|
||||
if err != nil {
|
||||
return core.NewServErr("删除缓存失败", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -353,6 +380,89 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转换交易
|
||||
func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) error {
|
||||
|
||||
// 检查订单状态
|
||||
result, err := s.CheckTrade(ref)
|
||||
if err != nil {
|
||||
return core.NewServErr("检查订单状态失败", err)
|
||||
}
|
||||
if result.Status != m.TradeStatusSuccess {
|
||||
switch result.Status {
|
||||
case m.TradeStatusPending:
|
||||
return core.NewBizErr("订单未支付")
|
||||
case m.TradeStatusCanceled:
|
||||
return core.NewBizErr("订单已过期")
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交易信息
|
||||
trade, err := q.Trade.
|
||||
Where(q.Trade.InnerNo.Eq(ref.TradeNo)).
|
||||
Take()
|
||||
if err != nil {
|
||||
return core.NewBizErr("获取交易信息失败", err)
|
||||
}
|
||||
|
||||
// 检查交易状态
|
||||
switch trade.Status {
|
||||
case m.TradeStatusCanceled:
|
||||
return core.NewBizErr("交易已取消")
|
||||
case m.TradeStatusSuccess:
|
||||
return nil // 跳过更新交易信息
|
||||
case m.TradeStatusPending:
|
||||
}
|
||||
|
||||
// 恢复购买信息;如果反序列化失败,检查开头 init 函数中是否注册了对应的 struct 类型
|
||||
tradeKey := tradeProductKey(ref.TradeNo)
|
||||
detailBytes, err := g.Redis.Get(context.Background(), tradeKey).Bytes()
|
||||
if err != nil {
|
||||
return core.NewServErr("恢复购买信息失败", err)
|
||||
}
|
||||
|
||||
var detail TradeDetail
|
||||
r := bytes.NewReader(detailBytes)
|
||||
if err := gob.NewDecoder(r).Decode(&detail); err != nil {
|
||||
return core.NewServErr("解析购买信息失败", err)
|
||||
}
|
||||
|
||||
user, err := q.User.Where(q.User.ID.Eq(trade.UserID)).First()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return q.Q.Transaction(func(q *q.Query) error {
|
||||
|
||||
// 更新交易信息
|
||||
r, err := q.Trade.
|
||||
Where(
|
||||
q.Trade.InnerNo.Eq(ref.TradeNo),
|
||||
q.Trade.Status.Eq(int(m.TradeStatusPending)),
|
||||
).
|
||||
UpdateSimple(
|
||||
q.Trade.Status.Value(int(m.TradeStatusSuccess)),
|
||||
q.Trade.OuterNo.Value(result.TransId),
|
||||
q.Trade.Payment.Value(result.Success.Actual),
|
||||
q.Trade.Acquirer.Value(int(result.Success.Acquirer)),
|
||||
q.Trade.CompletedAt.Value(result.Success.Time),
|
||||
)
|
||||
if err != nil {
|
||||
return core.NewServErr("更新交易信息失败", err)
|
||||
}
|
||||
if r.RowsAffected == 0 {
|
||||
return core.NewBizErr("交易状态已过期")
|
||||
}
|
||||
|
||||
// 更新用户余额
|
||||
if err := User.UpdateBalance(q, user, detail.Actual, "管理员订单补余额", &admin.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 取消交易
|
||||
func (s *tradeService) CancelTrade(ref *TradeRef) error {
|
||||
now := time.Now()
|
||||
@@ -394,7 +504,7 @@ func (s *tradeService) CancelTrade(ref *TradeRef) error {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Debug(fmt.Sprintf("订单无需关闭: %s", err.Error()))
|
||||
return nil
|
||||
return ErrTradeStatusIgnored
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -575,6 +685,12 @@ func (s *tradeService) CheckTrade(ref *TradeRef) (*CheckTradeResult, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// 更新备注
|
||||
func (s *tradeService) UpdateRemark(tradeNo string, remark string) error {
|
||||
_, err := q.Trade.Where(q.Trade.InnerNo.Eq(tradeNo)).UpdateColumn(q.Trade.Remark, remark)
|
||||
return err
|
||||
}
|
||||
|
||||
func tradeProductKey(no string) string {
|
||||
return fmt.Sprintf("trade:%s:product", no)
|
||||
}
|
||||
@@ -639,4 +755,5 @@ func (e TradeErr) Error() string {
|
||||
|
||||
var (
|
||||
ErrTransactionNotSupported = core.NewBizErr("不支持的支付方式")
|
||||
ErrTradeStatusIgnored = core.NewBizErr("交易状态已忽略")
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *userService) Get(q *q.Query, uid int32) (*m.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Decimal, adminId *int32) error {
|
||||
func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Decimal, remark string, adminId *int32) error {
|
||||
if user == nil {
|
||||
return core.NewServErr("用户不存在")
|
||||
}
|
||||
@@ -39,14 +39,14 @@ func (s *userService) UpdateBalanceByAdmin(user *m.User, newBalance decimal.Deci
|
||||
}
|
||||
|
||||
return q.Q.Transaction(func(q *q.Query) error {
|
||||
return s.UpdateBalance(q, user, amount, "管理员修改余额", adminId, nil)
|
||||
return s.UpdateBalance(q, user, amount, remark, adminId, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *userService) UpdateBalance(q *q.Query, user *m.User, amount decimal.Decimal, remark string, adminId *int32, billId *int32) error {
|
||||
balance := user.Balance.Add(amount)
|
||||
if balance.IsNegative() {
|
||||
return core.NewServErr("用户余额不足")
|
||||
return core.NewBizErr("用户余额不足")
|
||||
}
|
||||
|
||||
// 更新余额
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *verifierService) SendSms(ctx context.Context, phone string, purpose Ver
|
||||
}
|
||||
if response.Body.Code == nil || *response.Body.Code != "OK" {
|
||||
_ = g.Redis.Del(ctx, key, keyLock).Err()
|
||||
return VerifierServiceError("验证码发送失败")
|
||||
return VerifierServiceError(fmt.Sprintf("验证码发送失败: %s", *response.Body.Message))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ package tasks
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"platform/web/events"
|
||||
q "platform/web/queries"
|
||||
m "platform/web/models"
|
||||
s "platform/web/services"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -24,19 +25,30 @@ func HandleCompleteTrade(_ context.Context, task *asynq.Task) error {
|
||||
Method: event.Method,
|
||||
}
|
||||
|
||||
// 尝试完成交易
|
||||
user, err := s.User.Get(q.Q, event.UserId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取用户失败: %w", err)
|
||||
// 关闭交易
|
||||
err := s.Trade.CancelTrade(&data)
|
||||
if errors.Is(err, s.ErrTradeStatusIgnored) {
|
||||
result, err := s.Trade.CheckTrade(&data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查交易状态失败: %w", err)
|
||||
}
|
||||
|
||||
switch result.Status {
|
||||
case m.TradeStatusSuccess:
|
||||
if err := s.Trade.UpdateRemark(data.TradeNo, "已付款"); err != nil {
|
||||
slog.Error("添加备注失败", "err", err)
|
||||
}
|
||||
case m.TradeStatusCanceled:
|
||||
slog.Debug("交易已取消", "status", result.Status)
|
||||
default:
|
||||
return fmt.Errorf("意外交易状态: %v", result.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.Trade.CompleteTrade(user, &data); err != nil {
|
||||
slog.Debug("结束交易失败:完成交易失败", "err", err)
|
||||
|
||||
// 交易无法完成,关闭交易
|
||||
if err := s.Trade.CancelTrade(&data); err != nil {
|
||||
return fmt.Errorf("结束交易失败:取消交易失败: %w", err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("结束交易失败:取消交易失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -56,9 +68,9 @@ func HandleRemoveChannel(_ context.Context, task *asynq.Task) (err error) {
|
||||
func HandleRefreshEdges(_ context.Context, task *asynq.Task) (err error) {
|
||||
slog.Info("[event]刷新边缘节点")
|
||||
|
||||
err = s.Channel.RefreshEdges()
|
||||
if err != nil {
|
||||
return fmt.Errorf("刷新边缘节点失败: %w", err)
|
||||
}
|
||||
// err = s.Channel.RefreshEdges()
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("刷新边缘节点失败: %w", err)
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
27
web/web.go
27
web/web.go
@@ -42,10 +42,6 @@ func RunApp(pCtx context.Context) error {
|
||||
return RunTask(ctx)
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
return RunCron(ctx)
|
||||
})
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
@@ -111,29 +107,6 @@ func RunTask(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunCron(ctx context.Context) error {
|
||||
cron := asynq.NewSchedulerFromRedisClient(deps.Redis, &asynq.SchedulerOpts{
|
||||
Logger: &AppAsynqLogger{},
|
||||
Location: time.Local,
|
||||
})
|
||||
|
||||
cron.Register("0/10 * * * *", events.NewRefreshEdge())
|
||||
|
||||
// 停止服务
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
cron.Shutdown()
|
||||
}()
|
||||
|
||||
// 启动服务
|
||||
err := cron.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("定时任务服务运行失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppAsynqLogger struct{}
|
||||
|
||||
func (l *AppAsynqLogger) Debug(args ...any) {
|
||||
|
||||
Reference in New Issue
Block a user