Compare commits
9 Commits
5bb65216a9
...
d552f3bc67
| Author | SHA1 | Date | |
|---|---|---|---|
| d552f3bc67 | |||
| a456686551 | |||
| 2f0c6e6cb7 | |||
| 5103c08c2b | |||
| 83d38956ca | |||
| d961538027 | |||
| 5d00111887 | |||
| 3de4947207 | |||
| f42e5616b4 |
@@ -59,3 +59,4 @@ SFTPAY_ROUTE_ID=
|
|||||||
SFTPAY_APP_PRIVATE_KEY=
|
SFTPAY_APP_PRIVATE_KEY=
|
||||||
SFTPAY_PUBLIC_KEY=
|
SFTPAY_PUBLIC_KEY=
|
||||||
SFTPAY_RETURN_URL=
|
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",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
46
README.md
46
README.md
@@ -1,9 +1,29 @@
|
|||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
后台交易备注
|
清除代理的并发问题,redis 归还后,还有可能 gost 没有完成解除占用,导致端口冲突,考虑先等几秒再释放 redis
|
||||||
|
|
||||||
|
现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划
|
||||||
|
|
||||||
|
后台首页接口:
|
||||||
|
|
||||||
|
- 待认领客户数量
|
||||||
|
- 活跃代理数
|
||||||
|
- 今日交易额
|
||||||
|
- 今日提取数量
|
||||||
|
- 今日新增客户
|
||||||
|
- 每日活跃代理
|
||||||
|
- 每日交易额
|
||||||
|
- 每日新增客户
|
||||||
|
- 每日提取量
|
||||||
|
- 全局可提取统计(包时套餐计算最大可提取,每日提取上限 * 剩余可提取天数)
|
||||||
|
- 长效套餐
|
||||||
|
- 短效套餐
|
||||||
|
- 最近活跃用户(七日内 ip 使用量)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
关闭支付弹窗
|
关闭支付弹窗
|
||||||
兜底手动操作
|
后台限制 ip
|
||||||
限速
|
|
||||||
|
|
||||||
- edge.area_id 可为空,代表节点无固定地区
|
- edge.area_id 可为空,代表节点无固定地区
|
||||||
- 后台展示 mac, ip:port,实际地区
|
- 后台展示 mac, ip:port,实际地区
|
||||||
@@ -34,20 +54,24 @@
|
|||||||
1. 创建 model 文件
|
1. 创建 model 文件
|
||||||
2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中
|
2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中
|
||||||
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
||||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
|
||||||
- 为有必要的字段添加索引
|
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||||
- 为数据表及其字段添加注释
|
- 为有必要的字段添加索引
|
||||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
- 为数据表及其字段添加注释
|
||||||
|
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||||
|
|
||||||
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
|
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
|
||||||
|
|
||||||
### 更新数据表流程
|
### 更新数据表流程
|
||||||
|
|
||||||
1. 更新 model 文件
|
1. 更新 model 文件
|
||||||
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
|
||||||
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
|
||||||
- 为有必要的字段添加索引
|
- 先写 drop table if exists 语句,确保脚本可以幂等执行
|
||||||
- 为数据表及其字段添加注释
|
- 为有必要的字段添加索引
|
||||||
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
- 为数据表及其字段添加注释
|
||||||
|
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
|
||||||
|
|
||||||
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
|
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
|
||||||
|
|
||||||
### 新增接口或修改接口权限
|
### 新增接口或修改接口权限
|
||||||
|
|||||||
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/redis/go-redis/v9 v9.17.2
|
||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/smartwalle/alipay/v3 v3.2.28
|
github.com/smartwalle/alipay/v3 v3.2.28
|
||||||
github.com/valyala/fasthttp v1.68.0
|
|
||||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
|
||||||
go.opentelemetry.io/otel v1.43.0
|
go.opentelemetry.io/otel v1.43.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.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/spf13/cast v1.10.0 // indirect
|
||||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // 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/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib v1.38.0 // indirect
|
go.opentelemetry.io/contrib v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ insert into permission (parent_id, name, description, sort) values
|
|||||||
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);
|
((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 子权限
|
-- user:read 子权限
|
||||||
insert into permission (parent_id, name, description, sort) values
|
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),
|
((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 (
|
create table article (
|
||||||
id int generated by default as identity primary key,
|
id int generated by default as identity primary key,
|
||||||
group_id int not null,
|
group_id int not null,
|
||||||
|
code text,
|
||||||
title text not null,
|
title text not null,
|
||||||
content text,
|
content text,
|
||||||
sort int not null default 0,
|
sort int not null default 0,
|
||||||
@@ -183,6 +184,7 @@ create table article (
|
|||||||
updated_at timestamptz default current_timestamp,
|
updated_at timestamptz default current_timestamp,
|
||||||
deleted_at timestamptz
|
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_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_status on article (status) where deleted_at is null;
|
||||||
create index idx_article_sort on article (sort) 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 table article is '文章表';
|
||||||
comment on column article.id is '文章ID';
|
comment on column article.id is '文章ID';
|
||||||
comment on column article.group_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.title is '文章标题';
|
||||||
comment on column article.content is '文章内容';
|
comment on column article.content is '文章内容';
|
||||||
comment on column article.sort is '文章排序';
|
comment on column article.sort is '文章排序';
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ const (
|
|||||||
ScopeDiscountRead = string("discount:read") // 读取折扣列表
|
ScopeDiscountRead = string("discount:read") // 读取折扣列表
|
||||||
ScopeDiscountWrite = string("discount:write") // 写入折扣
|
ScopeDiscountWrite = string("discount:write") // 写入折扣
|
||||||
|
|
||||||
ScopeResource = string("resource") // 用户套餐
|
ScopeResource = string("resource") // 用户套餐
|
||||||
ScopeResourceRead = string("resource:read") // 读取用户套餐列表
|
ScopeResourceRead = string("resource:read") // 读取用户套餐列表
|
||||||
ScopeResourceWrite = string("resource:write") // 写入用户套餐
|
ScopeResourceWrite = string("resource:write") // 写入用户套餐
|
||||||
|
ScopeResourceReadOfUser = string("resource:read:of_user") // 读取指定用户的用户套餐列表
|
||||||
|
|
||||||
ScopeResourceShort = string("resource:short") // 短效动态套餐
|
ScopeResourceShort = string("resource:short") // 短效动态套餐
|
||||||
ScopeResourceShortRead = string("resource:short:read") // 读取用户短效动态套餐列表
|
ScopeResourceShortRead = string("resource:short:read") // 读取用户短效动态套餐列表
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type GostServiceConfig struct {
|
|||||||
Listener GostListenerConfig `json:"listener"`
|
Listener GostListenerConfig `json:"listener"`
|
||||||
Recorders []GostRecorderConfig `json:"recorders,omitempty"`
|
Recorders []GostRecorderConfig `json:"recorders,omitempty"`
|
||||||
Limiter string `json:"limiter,omitempty"`
|
Limiter string `json:"limiter,omitempty"`
|
||||||
|
Bypasses []string `json:"bypasses,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GostHandlerConfig struct {
|
type GostHandlerConfig struct {
|
||||||
|
|||||||
@@ -443,3 +443,11 @@ const (
|
|||||||
SftRefundIng SftTradeState = "REFUND_ING"
|
SftRefundIng SftTradeState = "REFUND_ING"
|
||||||
SftTradeClosed SftTradeState = "TRADE_CLOSED"
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var req core.IdReq
|
var req s.GetArticleReq
|
||||||
if err := g.Validator.ParseBody(c, &req); err != nil {
|
if err := g.Validator.ParseBody(c, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
article, err := s.Article.GetPublic(req.Id)
|
article, err := s.Article.GetPublic(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"
|
"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 分页查询当前用户短效套餐
|
// PageResourceShort 分页查询当前用户短效套餐
|
||||||
func PageResourceShort(c *fiber.Ctx) error {
|
func PageResourceShort(c *fiber.Ctx) error {
|
||||||
// 检查权限
|
// 检查权限
|
||||||
@@ -209,6 +379,148 @@ type PageResourceLongReq struct {
|
|||||||
Status *int `json:"status"` // 0 - 全部,1 - 有效,2 - 过期
|
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 分页查询全部短效套餐
|
// PageResourceShortByAdmin 分页查询全部短效套餐
|
||||||
func PageResourceShortByAdmin(c *fiber.Ctx) error {
|
func PageResourceShortByAdmin(c *fiber.Ctx) error {
|
||||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead)
|
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortRead)
|
||||||
@@ -393,6 +705,145 @@ type PageResourceLongByAdminReq struct {
|
|||||||
Expired *bool `json:"expired" form:"expired"`
|
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 分页查询指定用户的短效套餐
|
// PageResourceShortOfUserByAdmin 分页查询指定用户的短效套餐
|
||||||
func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
|
func PageResourceShortOfUserByAdmin(c *fiber.Ctx) error {
|
||||||
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser)
|
_, err := auth.GetAuthCtx(c).PermitAdmin(core.ScopeResourceShortReadOfUser)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"platform/web/auth"
|
"platform/web/auth"
|
||||||
"platform/web/core"
|
"platform/web/core"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageTradeByAdmin 分页查询所有订单
|
// PageTradeByAdmin 分页查询所有订单
|
||||||
@@ -351,6 +353,24 @@ func TradeCancel(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理员取消订单
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
// 结束订单:完成或取消订单
|
// 结束订单:完成或取消订单
|
||||||
@@ -411,3 +431,99 @@ func TradeCheck(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(result)
|
return c.JSON(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 交易回调
|
||||||
|
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)
|
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 {
|
func BindAdmin(c *fiber.Ctx) error {
|
||||||
// 检查权限
|
// 检查权限
|
||||||
@@ -514,7 +482,7 @@ func UpdateUserBalanceIncByAdmin(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newBalance := user.Balance.Add(amount)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +515,7 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newBalance := user.Balance.Sub(amount)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,4 +525,5 @@ func UpdateUserBalanceDecByAdmin(c *fiber.Ctx) error {
|
|||||||
type UpdateUserBalanceChangeByAdminData struct {
|
type UpdateUserBalanceChangeByAdminData struct {
|
||||||
UserID int32 `json:"user_id" validate:"required"`
|
UserID int32 `json:"user_id" validate:"required"`
|
||||||
Amount string `json:"amount" 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,
|
EnableStackTrace: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// metric
|
// otel
|
||||||
app.Use(otelfiber.Middleware())
|
app.Use(otelfiber.Middleware())
|
||||||
|
|
||||||
// logger
|
// logger
|
||||||
app.Use(logger.New(logger.Config{
|
app.Use(logger.New(logger.Config{
|
||||||
|
TimeFormat: "2006-01-02 15:04:05",
|
||||||
Next: func(c *fiber.Ctx) bool {
|
Next: func(c *fiber.Ctx) bool {
|
||||||
return c.Path() == "/favicon.ico"
|
return c.Path() == "/favicon.ico"
|
||||||
},
|
},
|
||||||
@@ -44,12 +45,23 @@ func ApplyMiddlewares(app *fiber.App) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
str := ""
|
|
||||||
if err != nil {
|
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
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import "platform/web/core"
|
|||||||
type Article struct {
|
type Article struct {
|
||||||
core.Model
|
core.Model
|
||||||
GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID
|
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"` // 文章标题
|
Title string `json:"title" gorm:"column:title"` // 文章标题
|
||||||
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
|
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
|
||||||
Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序
|
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.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_article.DeletedAt = field.NewField(tableName, "deleted_at")
|
_article.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||||
_article.GroupID = field.NewInt32(tableName, "group_id")
|
_article.GroupID = field.NewInt32(tableName, "group_id")
|
||||||
|
_article.Code = field.NewString(tableName, "code")
|
||||||
_article.Title = field.NewString(tableName, "title")
|
_article.Title = field.NewString(tableName, "title")
|
||||||
_article.Content = field.NewString(tableName, "content")
|
_article.Content = field.NewString(tableName, "content")
|
||||||
_article.Sort = field.NewInt32(tableName, "sort")
|
_article.Sort = field.NewInt32(tableName, "sort")
|
||||||
@@ -56,6 +57,7 @@ type article struct {
|
|||||||
UpdatedAt field.Time
|
UpdatedAt field.Time
|
||||||
DeletedAt field.Field
|
DeletedAt field.Field
|
||||||
GroupID field.Int32
|
GroupID field.Int32
|
||||||
|
Code field.String
|
||||||
Title field.String
|
Title field.String
|
||||||
Content field.String
|
Content field.String
|
||||||
Sort field.Int32
|
Sort field.Int32
|
||||||
@@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article {
|
|||||||
a.UpdatedAt = field.NewTime(table, "updated_at")
|
a.UpdatedAt = field.NewTime(table, "updated_at")
|
||||||
a.DeletedAt = field.NewField(table, "deleted_at")
|
a.DeletedAt = field.NewField(table, "deleted_at")
|
||||||
a.GroupID = field.NewInt32(table, "group_id")
|
a.GroupID = field.NewInt32(table, "group_id")
|
||||||
|
a.Code = field.NewString(table, "code")
|
||||||
a.Title = field.NewString(table, "title")
|
a.Title = field.NewString(table, "title")
|
||||||
a.Content = field.NewString(table, "content")
|
a.Content = field.NewString(table, "content")
|
||||||
a.Sort = field.NewInt32(table, "sort")
|
a.Sort = field.NewInt32(table, "sort")
|
||||||
@@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *article) fillFieldMap() {
|
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["id"] = a.ID
|
||||||
a.fieldMap["created_at"] = a.CreatedAt
|
a.fieldMap["created_at"] = a.CreatedAt
|
||||||
a.fieldMap["updated_at"] = a.UpdatedAt
|
a.fieldMap["updated_at"] = a.UpdatedAt
|
||||||
a.fieldMap["deleted_at"] = a.DeletedAt
|
a.fieldMap["deleted_at"] = a.DeletedAt
|
||||||
a.fieldMap["group_id"] = a.GroupID
|
a.fieldMap["group_id"] = a.GroupID
|
||||||
|
a.fieldMap["code"] = a.Code
|
||||||
a.fieldMap["title"] = a.Title
|
a.fieldMap["title"] = a.Title
|
||||||
a.fieldMap["content"] = a.Content
|
a.fieldMap["content"] = a.Content
|
||||||
a.fieldMap["sort"] = a.Sort
|
a.fieldMap["sort"] = a.Sort
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ func ApplyRouters(app *fiber.App) {
|
|||||||
// 回调
|
// 回调
|
||||||
callbacks := app.Group("/callback")
|
callbacks := app.Group("/callback")
|
||||||
callbacks.Get("/identify", handlers.IdentifyCallbackNew)
|
callbacks.Get("/identify", handlers.IdentifyCallbackNew)
|
||||||
|
callbacks.All("/trade/sft", handlers.TradeCallbackSft)
|
||||||
|
|
||||||
// 临时
|
// 临时
|
||||||
if env.RunMode == env.RunModeDev {
|
if env.RunMode == env.RunModeDev {
|
||||||
@@ -112,6 +113,7 @@ func userRouter(api fiber.Router) {
|
|||||||
// 套餐
|
// 套餐
|
||||||
resource := api.Group("/resource")
|
resource := api.Group("/resource")
|
||||||
resource.Post("/all", handlers.AllActiveResource)
|
resource.Post("/all", handlers.AllActiveResource)
|
||||||
|
resource.Post("/list", handlers.PageResource)
|
||||||
resource.Post("/list/short", handlers.PageResourceShort)
|
resource.Post("/list/short", handlers.PageResourceShort)
|
||||||
resource.Post("/list/long", handlers.PageResourceLong)
|
resource.Post("/list/long", handlers.PageResourceLong)
|
||||||
resource.Post("/create", handlers.CreateResource)
|
resource.Post("/create", handlers.CreateResource)
|
||||||
@@ -168,6 +170,12 @@ func userRouter(api fiber.Router) {
|
|||||||
func adminRouter(api fiber.Router) {
|
func adminRouter(api fiber.Router) {
|
||||||
api = api.Group("/admin")
|
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 管理员
|
// admin 管理员
|
||||||
var admin = api.Group("/admin")
|
var admin = api.Group("/admin")
|
||||||
admin.Post("/all", handlers.AllAdminByAdmin)
|
admin.Post("/all", handlers.AllAdminByAdmin)
|
||||||
@@ -199,12 +207,13 @@ func adminRouter(api fiber.Router) {
|
|||||||
user.Post("/remove", handlers.RemoveUserByAdmin)
|
user.Post("/remove", handlers.RemoveUserByAdmin)
|
||||||
|
|
||||||
user.Post("/update/bind", handlers.BindAdmin)
|
user.Post("/update/bind", handlers.BindAdmin)
|
||||||
user.Post("/update/balance", handlers.UpdateUserBalanceByAdmin)
|
|
||||||
user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin)
|
user.Post("/update/balance-inc", handlers.UpdateUserBalanceIncByAdmin)
|
||||||
user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin)
|
user.Post("/update/balance-dec", handlers.UpdateUserBalanceDecByAdmin)
|
||||||
|
|
||||||
// resource 套餐
|
// resource 套餐
|
||||||
var resource = api.Group("/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", handlers.PageResourceShortByAdmin)
|
||||||
resource.Post("/short/page/of-user", handlers.PageResourceShortOfUserByAdmin)
|
resource.Post("/short/page/of-user", handlers.PageResourceShortOfUserByAdmin)
|
||||||
resource.Post("/long/page", handlers.PageResourceLongByAdmin)
|
resource.Post("/long/page", handlers.PageResourceLongByAdmin)
|
||||||
@@ -238,6 +247,8 @@ func adminRouter(api fiber.Router) {
|
|||||||
trade.Post("/page", handlers.PageTradeByAdmin)
|
trade.Post("/page", handlers.PageTradeByAdmin)
|
||||||
trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin)
|
trade.Post("/page/of-user", handlers.PageTradeOfUserByAdmin)
|
||||||
trade.Post("/complete", handlers.TradeCompleteByAdmin)
|
trade.Post("/complete", handlers.TradeCompleteByAdmin)
|
||||||
|
trade.Post("/convert", handlers.TradeConvertByAdmin)
|
||||||
|
trade.Post("/cancel", handlers.TradeCancelByAdmin)
|
||||||
trade.Post("/update/remark", handlers.TradeUpdateRemarkByAdmin)
|
trade.Post("/update/remark", handlers.TradeUpdateRemarkByAdmin)
|
||||||
trade.Post("/check", handlers.TradeCheck)
|
trade.Post("/check", handlers.TradeCheck)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"platform/web/core"
|
"platform/web/core"
|
||||||
m "platform/web/models"
|
m "platform/web/models"
|
||||||
q "platform/web/queries"
|
q "platform/web/queries"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{
|
|||||||
"image/webp": ".webp",
|
"image/webp": ".webp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||||
|
|
||||||
type ArticleUploadResult struct {
|
type ArticleUploadResult struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Path string `json:"path"`
|
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) {
|
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
|
||||||
do := q.Article.Where()
|
do := q.Article.Where()
|
||||||
if req.Keyword != nil && *req.Keyword != "" {
|
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 {
|
if req.GroupID != nil {
|
||||||
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
|
do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
|
||||||
@@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error {
|
|||||||
return err
|
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,
|
GroupID: data.GroupID,
|
||||||
|
Code: code,
|
||||||
Title: data.Title,
|
Title: data.Title,
|
||||||
Content: data.Content,
|
Content: data.Content,
|
||||||
Sort: u.Else(data.Sort, 0),
|
Sort: u.Else(data.Sort, 0),
|
||||||
Status: u.Else(data.Status, m.ArticleStatusEnabled),
|
Status: u.Else(data.Status, m.ArticleStatusEnabled),
|
||||||
})
|
})
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return core.NewBizErr("文档编码已存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateArticleData struct {
|
type CreateArticleData struct {
|
||||||
GroupID int32 `json:"group_id" validate:"required"`
|
GroupID int32 `json:"group_id" validate:"required"`
|
||||||
|
Code *string `json:"code"`
|
||||||
Title string `json:"title" validate:"required"`
|
Title string `json:"title" validate:"required"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
Sort *int32 `json:"sort"`
|
Sort *int32 `json:"sort"`
|
||||||
@@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error {
|
|||||||
if data.GroupID != nil {
|
if data.GroupID != nil {
|
||||||
do = append(do, q.Article.GroupID.Value(*data.GroupID))
|
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 {
|
if data.Title != nil {
|
||||||
do = append(do, q.Article.Title.Value(*data.Title))
|
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...)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error {
|
|||||||
type UpdateArticleData struct {
|
type UpdateArticleData struct {
|
||||||
ID int32 `json:"id" validate:"required"`
|
ID int32 `json:"id" validate:"required"`
|
||||||
GroupID *int32 `json:"group_id"`
|
GroupID *int32 `json:"group_id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
Sort *int32 `json:"sort"`
|
Sort *int32 `json:"sort"`
|
||||||
@@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
|
|||||||
}
|
}
|
||||||
group.Articles = append(group.Articles, &ArticleNavArticle{
|
group.Articles = append(group.Articles, &ArticleNavArticle{
|
||||||
ID: article.ID,
|
ID: article.ID,
|
||||||
|
Code: article.Code,
|
||||||
Title: article.Title,
|
Title: article.Title,
|
||||||
UpdatedAt: article.UpdatedAt,
|
UpdatedAt: article.UpdatedAt,
|
||||||
})
|
})
|
||||||
@@ -331,18 +363,40 @@ type ArticleNavGroup struct {
|
|||||||
|
|
||||||
type ArticleNavArticle struct {
|
type ArticleNavArticle struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
Code *string `json:"code,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
type GetArticleReq struct {
|
||||||
article, err := q.Article.
|
ID *int32 `json:"id"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) {
|
||||||
|
do := q.Article.
|
||||||
Preload(q.Article.Group).
|
Preload(q.Article.Group).
|
||||||
Where(
|
Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled)))
|
||||||
q.Article.ID.Eq(id),
|
|
||||||
q.Article.Status.Eq(int(m.ArticleStatusEnabled)),
|
switch {
|
||||||
).
|
case req.ID != nil && req.Code != nil:
|
||||||
Take()
|
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) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, core.NewBizErr("文档不存在")
|
return nil, core.NewBizErr("文档不存在")
|
||||||
}
|
}
|
||||||
@@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
|||||||
|
|
||||||
return &ArticlePublicDetail{
|
return &ArticlePublicDetail{
|
||||||
ID: article.ID,
|
ID: article.ID,
|
||||||
|
Code: article.Code,
|
||||||
Title: article.Title,
|
Title: article.Title,
|
||||||
Content: article.Content,
|
Content: article.Content,
|
||||||
UpdatedAt: article.UpdatedAt,
|
UpdatedAt: article.UpdatedAt,
|
||||||
@@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
|
|||||||
|
|
||||||
type ArticlePublicDetail struct {
|
type ArticlePublicDetail struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
|
Code *string `json:"code,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content *string `json:"content,omitempty"`
|
Content *string `json:"content,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error {
|
|||||||
}
|
}
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
m "platform/web/models"
|
m "platform/web/models"
|
||||||
q "platform/web/queries"
|
q "platform/web/queries"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gen/field"
|
"gorm.io/gen/field"
|
||||||
)
|
)
|
||||||
@@ -53,7 +54,8 @@ func (s *channelGostProvider) prepareCreate(ctx *channelCreateContext) (*channel
|
|||||||
Recorders: []g.GostRecorderConfig{
|
Recorders: []g.GostRecorderConfig{
|
||||||
{Name: "record-http-otel", Record: "recorder.service.handler"},
|
{Name: "record-http-otel", Record: "recorder.service.handler"},
|
||||||
},
|
},
|
||||||
Limiter: "limiter-8m",
|
Limiter: "limiter-8m",
|
||||||
|
Bypasses: []string{"china", "deny"},
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.AuthWhitelist {
|
if ctx.AuthWhitelist {
|
||||||
@@ -130,7 +132,12 @@ func (s *channelGostProvider) removeRemote(batchNo string, batch *usedChanBatch)
|
|||||||
return client.DeleteAdmission(admissionName)
|
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) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,8 +46,8 @@ func rebuildFreeChans(proxyID int32, addr netip.Addr) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
chans := make([]netip.AddrPort, 10000)
|
chans := make([]netip.AddrPort, 30000)
|
||||||
for i := range 10000 {
|
for i := range 30000 {
|
||||||
chans[i] = netip.AddrPortFrom(addr, uint16(i+10000))
|
chans[i] = netip.AddrPortFrom(addr, uint16(i+10000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ func (s *tradeService) Create(user *m.User, tradeData *CreateTradeData, productD
|
|||||||
Currency: "cny",
|
Currency: "cny",
|
||||||
ClientIp: "123.52.74.23",
|
ClientIp: "123.52.74.23",
|
||||||
OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")),
|
OrderTimeout: u.P(expireAt.Format("2006-01-02 15:04:05")),
|
||||||
|
NotifyUrl: &env.SftNotifyUrl,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -238,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 {
|
if err != nil {
|
||||||
return core.NewServErr("处理交易失败", err)
|
return core.NewServErr("处理交易失败", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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.
|
trade, err := q.Trade.
|
||||||
|
Preload(q.Trade.User).
|
||||||
Where(q.Trade.InnerNo.Eq(interNo)).
|
Where(q.Trade.InnerNo.Eq(interNo)).
|
||||||
Take()
|
Take()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.NewBizErr("获取交易信息失败", err)
|
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 {
|
switch trade.Status {
|
||||||
@@ -357,9 +382,49 @@ func (s *tradeService) OnCompleteTrade(user *m.User, interNo string, outerNo str
|
|||||||
|
|
||||||
// 转换交易
|
// 转换交易
|
||||||
func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) error {
|
func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) error {
|
||||||
trade, err := q.Trade.Where(q.Trade.InnerNo.Eq(ref.TradeNo)).First()
|
|
||||||
|
// 检查订单状态
|
||||||
|
result, err := s.CheckTrade(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
user, err := q.User.Where(q.User.ID.Eq(trade.UserID)).First()
|
||||||
@@ -368,7 +433,33 @@ func (s *tradeService) ConvertTradeToBalance(admin *m.Admin, ref *TradeRef) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
return q.Q.Transaction(func(q *q.Query) error {
|
return q.Q.Transaction(func(q *q.Query) error {
|
||||||
return User.UpdateBalance(q, user, trade.Payment, "管理员订单补余额", &admin.ID, nil)
|
|
||||||
|
// 更新交易信息
|
||||||
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (s *userService) Get(q *q.Query, uid int32) (*m.User, error) {
|
|||||||
return user, nil
|
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 {
|
if user == nil {
|
||||||
return core.NewServErr("用户不存在")
|
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 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 {
|
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)
|
balance := user.Balance.Add(amount)
|
||||||
if balance.IsNegative() {
|
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" {
|
if response.Body.Code == nil || *response.Body.Code != "OK" {
|
||||||
_ = g.Redis.Del(ctx, key, keyLock).Err()
|
_ = g.Redis.Del(ctx, key, keyLock).Err()
|
||||||
return VerifierServiceError("验证码发送失败")
|
return VerifierServiceError(fmt.Sprintf("验证码发送失败: %s", *response.Body.Message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user