Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
d3062b2e
Unverified
Commit
d3062b2e
authored
Feb 02, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 02, 2026
Browse files
Merge pull request #434 from DuckyProject/feat/announcement-system-pr-upstream
feat(announcements): admin/user announcement system
parents
b7777fb4
9bee0a20
Changes
70
Hide whitespace changes
Inline
Side-by-side
backend/internal/domain/constants.go
0 → 100644
View file @
d3062b2e
package
domain
// Status constants
const
(
StatusActive
=
"active"
StatusDisabled
=
"disabled"
StatusError
=
"error"
StatusUnused
=
"unused"
StatusUsed
=
"used"
StatusExpired
=
"expired"
)
// Role constants
const
(
RoleAdmin
=
"admin"
RoleUser
=
"user"
)
// Platform constants
const
(
PlatformAnthropic
=
"anthropic"
PlatformOpenAI
=
"openai"
PlatformGemini
=
"gemini"
PlatformAntigravity
=
"antigravity"
)
// Account type constants
const
(
AccountTypeOAuth
=
"oauth"
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
"setup-token"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
)
// Redeem type constants
const
(
RedeemTypeBalance
=
"balance"
RedeemTypeConcurrency
=
"concurrency"
RedeemTypeSubscription
=
"subscription"
)
// PromoCode status constants
const
(
PromoCodeStatusActive
=
"active"
PromoCodeStatusDisabled
=
"disabled"
)
// Admin adjustment type constants
const
(
AdjustmentTypeAdminBalance
=
"admin_balance"
// 管理员调整余额
AdjustmentTypeAdminConcurrency
=
"admin_concurrency"
// 管理员调整并发数
)
// Group subscription type constants
const
(
SubscriptionTypeStandard
=
"standard"
// 标准计费模式(按余额扣费)
SubscriptionTypeSubscription
=
"subscription"
// 订阅模式(按限额控制)
)
// Subscription status constants
const
(
SubscriptionStatusActive
=
"active"
SubscriptionStatusExpired
=
"expired"
SubscriptionStatusSuspended
=
"suspended"
)
backend/internal/handler/admin/announcement_handler.go
0 → 100644
View file @
d3062b2e
package
admin
import
(
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementHandler handles admin announcement management
type
AnnouncementHandler
struct
{
announcementService
*
service
.
AnnouncementService
}
// NewAnnouncementHandler creates a new admin announcement handler
func
NewAnnouncementHandler
(
announcementService
*
service
.
AnnouncementService
)
*
AnnouncementHandler
{
return
&
AnnouncementHandler
{
announcementService
:
announcementService
,
}
}
type
CreateAnnouncementRequest
struct
{
Title
string
`json:"title" binding:"required"`
Content
string
`json:"content" binding:"required"`
Status
string
`json:"status" binding:"omitempty,oneof=draft active archived"`
Targeting
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
int64
`json:"starts_at"`
// Unix seconds, 0/empty = immediate
EndsAt
*
int64
`json:"ends_at"`
// Unix seconds, 0/empty = never
}
type
UpdateAnnouncementRequest
struct
{
Title
*
string
`json:"title"`
Content
*
string
`json:"content"`
Status
*
string
`json:"status" binding:"omitempty,oneof=draft active archived"`
Targeting
*
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
int64
`json:"starts_at"`
// Unix seconds, 0 = clear
EndsAt
*
int64
`json:"ends_at"`
// Unix seconds, 0 = clear
}
// List handles listing announcements with filters
// GET /api/v1/admin/announcements
func
(
h
*
AnnouncementHandler
)
List
(
c
*
gin
.
Context
)
{
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
status
:=
strings
.
TrimSpace
(
c
.
Query
(
"status"
))
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
200
{
search
=
search
[
:
200
]
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
}
items
,
paginationResult
,
err
:=
h
.
announcementService
.
List
(
c
.
Request
.
Context
(),
params
,
service
.
AnnouncementListFilters
{
Status
:
status
,
Search
:
search
},
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
Announcement
,
0
,
len
(
items
))
for
i
:=
range
items
{
out
=
append
(
out
,
*
dto
.
AnnouncementFromService
(
&
items
[
i
]))
}
response
.
Paginated
(
c
,
out
,
paginationResult
.
Total
,
page
,
pageSize
)
}
// GetByID handles getting an announcement by ID
// GET /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
GetByID
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
item
,
err
:=
h
.
announcementService
.
GetByID
(
c
.
Request
.
Context
(),
announcementID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
item
))
}
// Create handles creating a new announcement
// POST /api/v1/admin/announcements
func
(
h
*
AnnouncementHandler
)
Create
(
c
*
gin
.
Context
)
{
var
req
CreateAnnouncementRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
input
:=
&
service
.
CreateAnnouncementInput
{
Title
:
req
.
Title
,
Content
:
req
.
Content
,
Status
:
req
.
Status
,
Targeting
:
req
.
Targeting
,
ActorID
:
&
subject
.
UserID
,
}
if
req
.
StartsAt
!=
nil
&&
*
req
.
StartsAt
>
0
{
t
:=
time
.
Unix
(
*
req
.
StartsAt
,
0
)
input
.
StartsAt
=
&
t
}
if
req
.
EndsAt
!=
nil
&&
*
req
.
EndsAt
>
0
{
t
:=
time
.
Unix
(
*
req
.
EndsAt
,
0
)
input
.
EndsAt
=
&
t
}
created
,
err
:=
h
.
announcementService
.
Create
(
c
.
Request
.
Context
(),
input
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
created
))
}
// Update handles updating an announcement
// PUT /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
Update
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
var
req
UpdateAnnouncementRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
input
:=
&
service
.
UpdateAnnouncementInput
{
Title
:
req
.
Title
,
Content
:
req
.
Content
,
Status
:
req
.
Status
,
Targeting
:
req
.
Targeting
,
ActorID
:
&
subject
.
UserID
,
}
if
req
.
StartsAt
!=
nil
{
if
*
req
.
StartsAt
==
0
{
var
cleared
*
time
.
Time
=
nil
input
.
StartsAt
=
&
cleared
}
else
{
t
:=
time
.
Unix
(
*
req
.
StartsAt
,
0
)
ptr
:=
&
t
input
.
StartsAt
=
&
ptr
}
}
if
req
.
EndsAt
!=
nil
{
if
*
req
.
EndsAt
==
0
{
var
cleared
*
time
.
Time
=
nil
input
.
EndsAt
=
&
cleared
}
else
{
t
:=
time
.
Unix
(
*
req
.
EndsAt
,
0
)
ptr
:=
&
t
input
.
EndsAt
=
&
ptr
}
}
updated
,
err
:=
h
.
announcementService
.
Update
(
c
.
Request
.
Context
(),
announcementID
,
input
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
dto
.
AnnouncementFromService
(
updated
))
}
// Delete handles deleting an announcement
// DELETE /api/v1/admin/announcements/:id
func
(
h
*
AnnouncementHandler
)
Delete
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
if
err
:=
h
.
announcementService
.
Delete
(
c
.
Request
.
Context
(),
announcementID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"Announcement deleted successfully"
})
}
// ListReadStatus handles listing users read status for an announcement
// GET /api/v1/admin/announcements/:id/read-status
func
(
h
*
AnnouncementHandler
)
ListReadStatus
(
c
*
gin
.
Context
)
{
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
}
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
if
len
(
search
)
>
200
{
search
=
search
[
:
200
]
}
items
,
paginationResult
,
err
:=
h
.
announcementService
.
ListUserReadStatus
(
c
.
Request
.
Context
(),
announcementID
,
params
,
search
,
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
items
,
paginationResult
.
Total
,
page
,
pageSize
)
}
backend/internal/handler/announcement_handler.go
0 → 100644
View file @
d3062b2e
package
handler
import
(
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementHandler handles user announcement operations
type
AnnouncementHandler
struct
{
announcementService
*
service
.
AnnouncementService
}
// NewAnnouncementHandler creates a new user announcement handler
func
NewAnnouncementHandler
(
announcementService
*
service
.
AnnouncementService
)
*
AnnouncementHandler
{
return
&
AnnouncementHandler
{
announcementService
:
announcementService
,
}
}
// List handles listing announcements visible to current user
// GET /api/v1/announcements
func
(
h
*
AnnouncementHandler
)
List
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
unreadOnly
:=
parseBoolQuery
(
c
.
Query
(
"unread_only"
))
items
,
err
:=
h
.
announcementService
.
ListForUser
(
c
.
Request
.
Context
(),
subject
.
UserID
,
unreadOnly
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UserAnnouncement
,
0
,
len
(
items
))
for
i
:=
range
items
{
out
=
append
(
out
,
*
dto
.
UserAnnouncementFromService
(
&
items
[
i
]))
}
response
.
Success
(
c
,
out
)
}
// MarkRead marks an announcement as read for current user
// POST /api/v1/announcements/:id/read
func
(
h
*
AnnouncementHandler
)
MarkRead
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
if
err
:=
h
.
announcementService
.
MarkRead
(
c
.
Request
.
Context
(),
subject
.
UserID
,
announcementID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"ok"
})
}
func
parseBoolQuery
(
v
string
)
bool
{
switch
strings
.
TrimSpace
(
strings
.
ToLower
(
v
))
{
case
"1"
,
"true"
,
"yes"
,
"y"
,
"on"
:
return
true
default
:
return
false
}
}
backend/internal/handler/dto/announcement.go
0 → 100644
View file @
d3062b2e
package
dto
import
(
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
Announcement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
Status
string
`json:"status"`
Targeting
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
CreatedBy
*
int64
`json:"created_by,omitempty"`
UpdatedBy
*
int64
`json:"updated_by,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
type
UserAnnouncement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
ReadAt
*
time
.
Time
`json:"read_at,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
func
AnnouncementFromService
(
a
*
service
.
Announcement
)
*
Announcement
{
if
a
==
nil
{
return
nil
}
return
&
Announcement
{
ID
:
a
.
ID
,
Title
:
a
.
Title
,
Content
:
a
.
Content
,
Status
:
a
.
Status
,
Targeting
:
a
.
Targeting
,
StartsAt
:
a
.
StartsAt
,
EndsAt
:
a
.
EndsAt
,
CreatedBy
:
a
.
CreatedBy
,
UpdatedBy
:
a
.
UpdatedBy
,
CreatedAt
:
a
.
CreatedAt
,
UpdatedAt
:
a
.
UpdatedAt
,
}
}
func
UserAnnouncementFromService
(
a
*
service
.
UserAnnouncement
)
*
UserAnnouncement
{
if
a
==
nil
{
return
nil
}
return
&
UserAnnouncement
{
ID
:
a
.
Announcement
.
ID
,
Title
:
a
.
Announcement
.
Title
,
Content
:
a
.
Announcement
.
Content
,
StartsAt
:
a
.
Announcement
.
StartsAt
,
EndsAt
:
a
.
Announcement
.
EndsAt
,
ReadAt
:
a
.
ReadAt
,
CreatedAt
:
a
.
Announcement
.
CreatedAt
,
UpdatedAt
:
a
.
Announcement
.
UpdatedAt
,
}
}
backend/internal/handler/handler.go
View file @
d3062b2e
...
...
@@ -10,6 +10,7 @@ type AdminHandlers struct {
User
*
admin
.
UserHandler
Group
*
admin
.
GroupHandler
Account
*
admin
.
AccountHandler
Announcement
*
admin
.
AnnouncementHandler
OAuth
*
admin
.
OAuthHandler
OpenAIOAuth
*
admin
.
OpenAIOAuthHandler
GeminiOAuth
*
admin
.
GeminiOAuthHandler
...
...
@@ -33,6 +34,7 @@ type Handlers struct {
Usage
*
UsageHandler
Redeem
*
RedeemHandler
Subscription
*
SubscriptionHandler
Announcement
*
AnnouncementHandler
Admin
*
AdminHandlers
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
...
...
backend/internal/handler/wire.go
View file @
d3062b2e
...
...
@@ -13,6 +13,7 @@ func ProvideAdminHandlers(
userHandler
*
admin
.
UserHandler
,
groupHandler
*
admin
.
GroupHandler
,
accountHandler
*
admin
.
AccountHandler
,
announcementHandler
*
admin
.
AnnouncementHandler
,
oauthHandler
*
admin
.
OAuthHandler
,
openaiOAuthHandler
*
admin
.
OpenAIOAuthHandler
,
geminiOAuthHandler
*
admin
.
GeminiOAuthHandler
,
...
...
@@ -32,6 +33,7 @@ func ProvideAdminHandlers(
User
:
userHandler
,
Group
:
groupHandler
,
Account
:
accountHandler
,
Announcement
:
announcementHandler
,
OAuth
:
oauthHandler
,
OpenAIOAuth
:
openaiOAuthHandler
,
GeminiOAuth
:
geminiOAuthHandler
,
...
...
@@ -66,6 +68,7 @@ func ProvideHandlers(
usageHandler
*
UsageHandler
,
redeemHandler
*
RedeemHandler
,
subscriptionHandler
*
SubscriptionHandler
,
announcementHandler
*
AnnouncementHandler
,
adminHandlers
*
AdminHandlers
,
gatewayHandler
*
GatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
...
...
@@ -79,6 +82,7 @@ func ProvideHandlers(
Usage
:
usageHandler
,
Redeem
:
redeemHandler
,
Subscription
:
subscriptionHandler
,
Announcement
:
announcementHandler
,
Admin
:
adminHandlers
,
Gateway
:
gatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
...
...
@@ -96,6 +100,7 @@ var ProviderSet = wire.NewSet(
NewUsageHandler
,
NewRedeemHandler
,
NewSubscriptionHandler
,
NewAnnouncementHandler
,
NewGatewayHandler
,
NewOpenAIGatewayHandler
,
NewTotpHandler
,
...
...
@@ -106,6 +111,7 @@ var ProviderSet = wire.NewSet(
admin
.
NewUserHandler
,
admin
.
NewGroupHandler
,
admin
.
NewAccountHandler
,
admin
.
NewAnnouncementHandler
,
admin
.
NewOAuthHandler
,
admin
.
NewOpenAIOAuthHandler
,
admin
.
NewGeminiOAuthHandler
,
...
...
backend/internal/repository/announcement_read_repo.go
0 → 100644
View file @
d3062b2e
package
repository
import
(
"context"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/announcementread"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
announcementReadRepository
struct
{
client
*
dbent
.
Client
}
func
NewAnnouncementReadRepository
(
client
*
dbent
.
Client
)
service
.
AnnouncementReadRepository
{
return
&
announcementReadRepository
{
client
:
client
}
}
func
(
r
*
announcementReadRepository
)
MarkRead
(
ctx
context
.
Context
,
announcementID
,
userID
int64
,
readAt
time
.
Time
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
return
client
.
AnnouncementRead
.
Create
()
.
SetAnnouncementID
(
announcementID
)
.
SetUserID
(
userID
)
.
SetReadAt
(
readAt
)
.
OnConflictColumns
(
announcementread
.
FieldAnnouncementID
,
announcementread
.
FieldUserID
)
.
DoNothing
()
.
Exec
(
ctx
)
}
func
(
r
*
announcementReadRepository
)
GetReadMapByUser
(
ctx
context
.
Context
,
userID
int64
,
announcementIDs
[]
int64
)
(
map
[
int64
]
time
.
Time
,
error
)
{
if
len
(
announcementIDs
)
==
0
{
return
map
[
int64
]
time
.
Time
{},
nil
}
rows
,
err
:=
r
.
client
.
AnnouncementRead
.
Query
()
.
Where
(
announcementread
.
UserIDEQ
(
userID
),
announcementread
.
AnnouncementIDIn
(
announcementIDs
...
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
out
:=
make
(
map
[
int64
]
time
.
Time
,
len
(
rows
))
for
i
:=
range
rows
{
out
[
rows
[
i
]
.
AnnouncementID
]
=
rows
[
i
]
.
ReadAt
}
return
out
,
nil
}
func
(
r
*
announcementReadRepository
)
GetReadMapByUsers
(
ctx
context
.
Context
,
announcementID
int64
,
userIDs
[]
int64
)
(
map
[
int64
]
time
.
Time
,
error
)
{
if
len
(
userIDs
)
==
0
{
return
map
[
int64
]
time
.
Time
{},
nil
}
rows
,
err
:=
r
.
client
.
AnnouncementRead
.
Query
()
.
Where
(
announcementread
.
AnnouncementIDEQ
(
announcementID
),
announcementread
.
UserIDIn
(
userIDs
...
),
)
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
out
:=
make
(
map
[
int64
]
time
.
Time
,
len
(
rows
))
for
i
:=
range
rows
{
out
[
rows
[
i
]
.
UserID
]
=
rows
[
i
]
.
ReadAt
}
return
out
,
nil
}
func
(
r
*
announcementReadRepository
)
CountByAnnouncementID
(
ctx
context
.
Context
,
announcementID
int64
)
(
int64
,
error
)
{
count
,
err
:=
r
.
client
.
AnnouncementRead
.
Query
()
.
Where
(
announcementread
.
AnnouncementIDEQ
(
announcementID
))
.
Count
(
ctx
)
if
err
!=
nil
{
return
0
,
err
}
return
int64
(
count
),
nil
}
backend/internal/repository/announcement_repo.go
0 → 100644
View file @
d3062b2e
package
repository
import
(
"context"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/announcement"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
announcementRepository
struct
{
client
*
dbent
.
Client
}
func
NewAnnouncementRepository
(
client
*
dbent
.
Client
)
service
.
AnnouncementRepository
{
return
&
announcementRepository
{
client
:
client
}
}
func
(
r
*
announcementRepository
)
Create
(
ctx
context
.
Context
,
a
*
service
.
Announcement
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
builder
:=
client
.
Announcement
.
Create
()
.
SetTitle
(
a
.
Title
)
.
SetContent
(
a
.
Content
)
.
SetStatus
(
a
.
Status
)
.
SetTargeting
(
a
.
Targeting
)
if
a
.
StartsAt
!=
nil
{
builder
.
SetStartsAt
(
*
a
.
StartsAt
)
}
if
a
.
EndsAt
!=
nil
{
builder
.
SetEndsAt
(
*
a
.
EndsAt
)
}
if
a
.
CreatedBy
!=
nil
{
builder
.
SetCreatedBy
(
*
a
.
CreatedBy
)
}
if
a
.
UpdatedBy
!=
nil
{
builder
.
SetUpdatedBy
(
*
a
.
UpdatedBy
)
}
created
,
err
:=
builder
.
Save
(
ctx
)
if
err
!=
nil
{
return
err
}
applyAnnouncementEntityToService
(
a
,
created
)
return
nil
}
func
(
r
*
announcementRepository
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Announcement
,
error
)
{
m
,
err
:=
r
.
client
.
Announcement
.
Query
()
.
Where
(
announcement
.
IDEQ
(
id
))
.
Only
(
ctx
)
if
err
!=
nil
{
return
nil
,
translatePersistenceError
(
err
,
service
.
ErrAnnouncementNotFound
,
nil
)
}
return
announcementEntityToService
(
m
),
nil
}
func
(
r
*
announcementRepository
)
Update
(
ctx
context
.
Context
,
a
*
service
.
Announcement
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
builder
:=
client
.
Announcement
.
UpdateOneID
(
a
.
ID
)
.
SetTitle
(
a
.
Title
)
.
SetContent
(
a
.
Content
)
.
SetStatus
(
a
.
Status
)
.
SetTargeting
(
a
.
Targeting
)
if
a
.
StartsAt
!=
nil
{
builder
.
SetStartsAt
(
*
a
.
StartsAt
)
}
else
{
builder
.
ClearStartsAt
()
}
if
a
.
EndsAt
!=
nil
{
builder
.
SetEndsAt
(
*
a
.
EndsAt
)
}
else
{
builder
.
ClearEndsAt
()
}
if
a
.
CreatedBy
!=
nil
{
builder
.
SetCreatedBy
(
*
a
.
CreatedBy
)
}
else
{
builder
.
ClearCreatedBy
()
}
if
a
.
UpdatedBy
!=
nil
{
builder
.
SetUpdatedBy
(
*
a
.
UpdatedBy
)
}
else
{
builder
.
ClearUpdatedBy
()
}
updated
,
err
:=
builder
.
Save
(
ctx
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrAnnouncementNotFound
,
nil
)
}
a
.
UpdatedAt
=
updated
.
UpdatedAt
return
nil
}
func
(
r
*
announcementRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
_
,
err
:=
client
.
Announcement
.
Delete
()
.
Where
(
announcement
.
IDEQ
(
id
))
.
Exec
(
ctx
)
return
err
}
func
(
r
*
announcementRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
service
.
AnnouncementListFilters
,
)
([]
service
.
Announcement
,
*
pagination
.
PaginationResult
,
error
)
{
q
:=
r
.
client
.
Announcement
.
Query
()
if
filters
.
Status
!=
""
{
q
=
q
.
Where
(
announcement
.
StatusEQ
(
filters
.
Status
))
}
if
filters
.
Search
!=
""
{
q
=
q
.
Where
(
announcement
.
Or
(
announcement
.
TitleContainsFold
(
filters
.
Search
),
announcement
.
ContentContainsFold
(
filters
.
Search
),
),
)
}
total
,
err
:=
q
.
Count
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
items
,
err
:=
q
.
Offset
(
params
.
Offset
())
.
Limit
(
params
.
Limit
())
.
Order
(
dbent
.
Desc
(
announcement
.
FieldID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
out
:=
announcementEntitiesToService
(
items
)
return
out
,
paginationResultFromTotal
(
int64
(
total
),
params
),
nil
}
func
(
r
*
announcementRepository
)
ListActive
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
service
.
Announcement
,
error
)
{
q
:=
r
.
client
.
Announcement
.
Query
()
.
Where
(
announcement
.
StatusEQ
(
service
.
AnnouncementStatusActive
),
announcement
.
Or
(
announcement
.
StartsAtIsNil
(),
announcement
.
StartsAtLTE
(
now
)),
announcement
.
Or
(
announcement
.
EndsAtIsNil
(),
announcement
.
EndsAtGT
(
now
)),
)
.
Order
(
dbent
.
Desc
(
announcement
.
FieldID
))
items
,
err
:=
q
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
return
announcementEntitiesToService
(
items
),
nil
}
func
applyAnnouncementEntityToService
(
dst
*
service
.
Announcement
,
src
*
dbent
.
Announcement
)
{
if
dst
==
nil
||
src
==
nil
{
return
}
dst
.
ID
=
src
.
ID
dst
.
CreatedAt
=
src
.
CreatedAt
dst
.
UpdatedAt
=
src
.
UpdatedAt
}
func
announcementEntityToService
(
m
*
dbent
.
Announcement
)
*
service
.
Announcement
{
if
m
==
nil
{
return
nil
}
return
&
service
.
Announcement
{
ID
:
m
.
ID
,
Title
:
m
.
Title
,
Content
:
m
.
Content
,
Status
:
m
.
Status
,
Targeting
:
m
.
Targeting
,
StartsAt
:
m
.
StartsAt
,
EndsAt
:
m
.
EndsAt
,
CreatedBy
:
m
.
CreatedBy
,
UpdatedBy
:
m
.
UpdatedBy
,
CreatedAt
:
m
.
CreatedAt
,
UpdatedAt
:
m
.
UpdatedAt
,
}
}
func
announcementEntitiesToService
(
models
[]
*
dbent
.
Announcement
)
[]
service
.
Announcement
{
out
:=
make
([]
service
.
Announcement
,
0
,
len
(
models
))
for
i
:=
range
models
{
if
s
:=
announcementEntityToService
(
models
[
i
]);
s
!=
nil
{
out
=
append
(
out
,
*
s
)
}
}
return
out
}
backend/internal/repository/wire.go
View file @
d3062b2e
...
...
@@ -56,6 +56,8 @@ var ProviderSet = wire.NewSet(
NewProxyRepository
,
NewRedeemCodeRepository
,
NewPromoCodeRepository
,
NewAnnouncementRepository
,
NewAnnouncementReadRepository
,
NewUsageLogRepository
,
NewUsageCleanupRepository
,
NewDashboardAggregationRepository
,
...
...
backend/internal/server/routes/admin.go
View file @
d3062b2e
...
...
@@ -29,6 +29,9 @@ func RegisterAdminRoutes(
// 账号管理
registerAccountRoutes
(
admin
,
h
)
// 公告管理
registerAnnouncementRoutes
(
admin
,
h
)
// OpenAI OAuth
registerOpenAIOAuthRoutes
(
admin
,
h
)
...
...
@@ -229,6 +232,18 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
func
registerAnnouncementRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
announcements
:=
admin
.
Group
(
"/announcements"
)
{
announcements
.
GET
(
""
,
h
.
Admin
.
Announcement
.
List
)
announcements
.
POST
(
""
,
h
.
Admin
.
Announcement
.
Create
)
announcements
.
GET
(
"/:id"
,
h
.
Admin
.
Announcement
.
GetByID
)
announcements
.
PUT
(
"/:id"
,
h
.
Admin
.
Announcement
.
Update
)
announcements
.
DELETE
(
"/:id"
,
h
.
Admin
.
Announcement
.
Delete
)
announcements
.
GET
(
"/:id/read-status"
,
h
.
Admin
.
Announcement
.
ListReadStatus
)
}
}
func
registerOpenAIOAuthRoutes
(
admin
*
gin
.
RouterGroup
,
h
*
handler
.
Handlers
)
{
openai
:=
admin
.
Group
(
"/openai"
)
{
...
...
backend/internal/server/routes/user.go
View file @
d3062b2e
...
...
@@ -64,6 +64,13 @@ func RegisterUserRoutes(
usage
.
POST
(
"/dashboard/api-keys-usage"
,
h
.
Usage
.
DashboardAPIKeysUsage
)
}
// 公告(用户可见)
announcements
:=
authenticated
.
Group
(
"/announcements"
)
{
announcements
.
GET
(
""
,
h
.
Announcement
.
List
)
announcements
.
POST
(
"/:id/read"
,
h
.
Announcement
.
MarkRead
)
}
// 卡密兑换
redeem
:=
authenticated
.
Group
(
"/redeem"
)
{
...
...
backend/internal/service/announcement.go
0 → 100644
View file @
d3062b2e
package
service
import
(
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
const
(
AnnouncementStatusDraft
=
domain
.
AnnouncementStatusDraft
AnnouncementStatusActive
=
domain
.
AnnouncementStatusActive
AnnouncementStatusArchived
=
domain
.
AnnouncementStatusArchived
)
const
(
AnnouncementConditionTypeSubscription
=
domain
.
AnnouncementConditionTypeSubscription
AnnouncementConditionTypeBalance
=
domain
.
AnnouncementConditionTypeBalance
)
const
(
AnnouncementOperatorIn
=
domain
.
AnnouncementOperatorIn
AnnouncementOperatorGT
=
domain
.
AnnouncementOperatorGT
AnnouncementOperatorGTE
=
domain
.
AnnouncementOperatorGTE
AnnouncementOperatorLT
=
domain
.
AnnouncementOperatorLT
AnnouncementOperatorLTE
=
domain
.
AnnouncementOperatorLTE
AnnouncementOperatorEQ
=
domain
.
AnnouncementOperatorEQ
)
var
(
ErrAnnouncementNotFound
=
domain
.
ErrAnnouncementNotFound
ErrAnnouncementInvalidTarget
=
domain
.
ErrAnnouncementInvalidTarget
)
type
AnnouncementTargeting
=
domain
.
AnnouncementTargeting
type
AnnouncementConditionGroup
=
domain
.
AnnouncementConditionGroup
type
AnnouncementCondition
=
domain
.
AnnouncementCondition
type
Announcement
=
domain
.
Announcement
type
AnnouncementListFilters
struct
{
Status
string
Search
string
}
type
AnnouncementRepository
interface
{
Create
(
ctx
context
.
Context
,
a
*
Announcement
)
error
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Announcement
,
error
)
Update
(
ctx
context
.
Context
,
a
*
Announcement
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
AnnouncementListFilters
)
([]
Announcement
,
*
pagination
.
PaginationResult
,
error
)
ListActive
(
ctx
context
.
Context
,
now
time
.
Time
)
([]
Announcement
,
error
)
}
type
AnnouncementReadRepository
interface
{
MarkRead
(
ctx
context
.
Context
,
announcementID
,
userID
int64
,
readAt
time
.
Time
)
error
GetReadMapByUser
(
ctx
context
.
Context
,
userID
int64
,
announcementIDs
[]
int64
)
(
map
[
int64
]
time
.
Time
,
error
)
GetReadMapByUsers
(
ctx
context
.
Context
,
announcementID
int64
,
userIDs
[]
int64
)
(
map
[
int64
]
time
.
Time
,
error
)
CountByAnnouncementID
(
ctx
context
.
Context
,
announcementID
int64
)
(
int64
,
error
)
}
backend/internal/service/announcement_service.go
0 → 100644
View file @
d3062b2e
package
service
import
(
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
type
AnnouncementService
struct
{
announcementRepo
AnnouncementRepository
readRepo
AnnouncementReadRepository
userRepo
UserRepository
userSubRepo
UserSubscriptionRepository
}
func
NewAnnouncementService
(
announcementRepo
AnnouncementRepository
,
readRepo
AnnouncementReadRepository
,
userRepo
UserRepository
,
userSubRepo
UserSubscriptionRepository
,
)
*
AnnouncementService
{
return
&
AnnouncementService
{
announcementRepo
:
announcementRepo
,
readRepo
:
readRepo
,
userRepo
:
userRepo
,
userSubRepo
:
userSubRepo
,
}
}
type
CreateAnnouncementInput
struct
{
Title
string
Content
string
Status
string
Targeting
AnnouncementTargeting
StartsAt
*
time
.
Time
EndsAt
*
time
.
Time
ActorID
*
int64
// 管理员用户ID
}
type
UpdateAnnouncementInput
struct
{
Title
*
string
Content
*
string
Status
*
string
Targeting
*
AnnouncementTargeting
StartsAt
**
time
.
Time
EndsAt
**
time
.
Time
ActorID
*
int64
// 管理员用户ID
}
type
UserAnnouncement
struct
{
Announcement
Announcement
ReadAt
*
time
.
Time
}
type
AnnouncementUserReadStatus
struct
{
UserID
int64
`json:"user_id"`
Email
string
`json:"email"`
Username
string
`json:"username"`
Balance
float64
`json:"balance"`
Eligible
bool
`json:"eligible"`
ReadAt
*
time
.
Time
`json:"read_at,omitempty"`
}
func
(
s
*
AnnouncementService
)
Create
(
ctx
context
.
Context
,
input
*
CreateAnnouncementInput
)
(
*
Announcement
,
error
)
{
if
input
==
nil
{
return
nil
,
fmt
.
Errorf
(
"create announcement: nil input"
)
}
title
:=
strings
.
TrimSpace
(
input
.
Title
)
content
:=
strings
.
TrimSpace
(
input
.
Content
)
if
title
==
""
||
len
(
title
)
>
200
{
return
nil
,
fmt
.
Errorf
(
"create announcement: invalid title"
)
}
if
content
==
""
{
return
nil
,
fmt
.
Errorf
(
"create announcement: content is required"
)
}
status
:=
strings
.
TrimSpace
(
input
.
Status
)
if
status
==
""
{
status
=
AnnouncementStatusDraft
}
if
!
isValidAnnouncementStatus
(
status
)
{
return
nil
,
fmt
.
Errorf
(
"create announcement: invalid status"
)
}
targeting
,
err
:=
domain
.
AnnouncementTargeting
(
input
.
Targeting
)
.
NormalizeAndValidate
()
if
err
!=
nil
{
return
nil
,
err
}
if
input
.
StartsAt
!=
nil
&&
input
.
EndsAt
!=
nil
{
if
!
input
.
StartsAt
.
Before
(
*
input
.
EndsAt
)
{
return
nil
,
fmt
.
Errorf
(
"create announcement: starts_at must be before ends_at"
)
}
}
a
:=
&
Announcement
{
Title
:
title
,
Content
:
content
,
Status
:
status
,
Targeting
:
targeting
,
StartsAt
:
input
.
StartsAt
,
EndsAt
:
input
.
EndsAt
,
}
if
input
.
ActorID
!=
nil
&&
*
input
.
ActorID
>
0
{
a
.
CreatedBy
=
input
.
ActorID
a
.
UpdatedBy
=
input
.
ActorID
}
if
err
:=
s
.
announcementRepo
.
Create
(
ctx
,
a
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"create announcement: %w"
,
err
)
}
return
a
,
nil
}
func
(
s
*
AnnouncementService
)
Update
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdateAnnouncementInput
)
(
*
Announcement
,
error
)
{
if
input
==
nil
{
return
nil
,
fmt
.
Errorf
(
"update announcement: nil input"
)
}
a
,
err
:=
s
.
announcementRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
return
nil
,
err
}
if
input
.
Title
!=
nil
{
title
:=
strings
.
TrimSpace
(
*
input
.
Title
)
if
title
==
""
||
len
(
title
)
>
200
{
return
nil
,
fmt
.
Errorf
(
"update announcement: invalid title"
)
}
a
.
Title
=
title
}
if
input
.
Content
!=
nil
{
content
:=
strings
.
TrimSpace
(
*
input
.
Content
)
if
content
==
""
{
return
nil
,
fmt
.
Errorf
(
"update announcement: content is required"
)
}
a
.
Content
=
content
}
if
input
.
Status
!=
nil
{
status
:=
strings
.
TrimSpace
(
*
input
.
Status
)
if
!
isValidAnnouncementStatus
(
status
)
{
return
nil
,
fmt
.
Errorf
(
"update announcement: invalid status"
)
}
a
.
Status
=
status
}
if
input
.
Targeting
!=
nil
{
targeting
,
err
:=
domain
.
AnnouncementTargeting
(
*
input
.
Targeting
)
.
NormalizeAndValidate
()
if
err
!=
nil
{
return
nil
,
err
}
a
.
Targeting
=
targeting
}
if
input
.
StartsAt
!=
nil
{
a
.
StartsAt
=
*
input
.
StartsAt
}
if
input
.
EndsAt
!=
nil
{
a
.
EndsAt
=
*
input
.
EndsAt
}
if
a
.
StartsAt
!=
nil
&&
a
.
EndsAt
!=
nil
{
if
!
a
.
StartsAt
.
Before
(
*
a
.
EndsAt
)
{
return
nil
,
fmt
.
Errorf
(
"update announcement: starts_at must be before ends_at"
)
}
}
if
input
.
ActorID
!=
nil
&&
*
input
.
ActorID
>
0
{
a
.
UpdatedBy
=
input
.
ActorID
}
if
err
:=
s
.
announcementRepo
.
Update
(
ctx
,
a
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"update announcement: %w"
,
err
)
}
return
a
,
nil
}
func
(
s
*
AnnouncementService
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
if
err
:=
s
.
announcementRepo
.
Delete
(
ctx
,
id
);
err
!=
nil
{
return
fmt
.
Errorf
(
"delete announcement: %w"
,
err
)
}
return
nil
}
func
(
s
*
AnnouncementService
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Announcement
,
error
)
{
return
s
.
announcementRepo
.
GetByID
(
ctx
,
id
)
}
func
(
s
*
AnnouncementService
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
AnnouncementListFilters
)
([]
Announcement
,
*
pagination
.
PaginationResult
,
error
)
{
return
s
.
announcementRepo
.
List
(
ctx
,
params
,
filters
)
}
func
(
s
*
AnnouncementService
)
ListForUser
(
ctx
context
.
Context
,
userID
int64
,
unreadOnly
bool
)
([]
UserAnnouncement
,
error
)
{
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
activeSubs
,
err
:=
s
.
userSubRepo
.
ListActiveByUserID
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"list active subscriptions: %w"
,
err
)
}
activeGroupIDs
:=
make
(
map
[
int64
]
struct
{},
len
(
activeSubs
))
for
i
:=
range
activeSubs
{
activeGroupIDs
[
activeSubs
[
i
]
.
GroupID
]
=
struct
{}{}
}
now
:=
time
.
Now
()
anns
,
err
:=
s
.
announcementRepo
.
ListActive
(
ctx
,
now
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"list active announcements: %w"
,
err
)
}
visible
:=
make
([]
Announcement
,
0
,
len
(
anns
))
ids
:=
make
([]
int64
,
0
,
len
(
anns
))
for
i
:=
range
anns
{
a
:=
anns
[
i
]
if
!
a
.
IsActiveAt
(
now
)
{
continue
}
if
!
a
.
Targeting
.
Matches
(
user
.
Balance
,
activeGroupIDs
)
{
continue
}
visible
=
append
(
visible
,
a
)
ids
=
append
(
ids
,
a
.
ID
)
}
if
len
(
visible
)
==
0
{
return
[]
UserAnnouncement
{},
nil
}
readMap
,
err
:=
s
.
readRepo
.
GetReadMapByUser
(
ctx
,
userID
,
ids
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"get read map: %w"
,
err
)
}
out
:=
make
([]
UserAnnouncement
,
0
,
len
(
visible
))
for
i
:=
range
visible
{
a
:=
visible
[
i
]
readAt
,
ok
:=
readMap
[
a
.
ID
]
if
unreadOnly
&&
ok
{
continue
}
var
ptr
*
time
.
Time
if
ok
{
t
:=
readAt
ptr
=
&
t
}
out
=
append
(
out
,
UserAnnouncement
{
Announcement
:
a
,
ReadAt
:
ptr
,
})
}
// 未读优先、同状态按创建时间倒序
sort
.
Slice
(
out
,
func
(
i
,
j
int
)
bool
{
ai
,
aj
:=
out
[
i
],
out
[
j
]
if
(
ai
.
ReadAt
==
nil
)
!=
(
aj
.
ReadAt
==
nil
)
{
return
ai
.
ReadAt
==
nil
}
return
ai
.
Announcement
.
ID
>
aj
.
Announcement
.
ID
})
return
out
,
nil
}
func
(
s
*
AnnouncementService
)
MarkRead
(
ctx
context
.
Context
,
userID
,
announcementID
int64
)
error
{
// 安全:仅允许标记当前用户“可见”的公告
user
,
err
:=
s
.
userRepo
.
GetByID
(
ctx
,
userID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"get user: %w"
,
err
)
}
a
,
err
:=
s
.
announcementRepo
.
GetByID
(
ctx
,
announcementID
)
if
err
!=
nil
{
return
err
}
now
:=
time
.
Now
()
if
!
a
.
IsActiveAt
(
now
)
{
return
ErrAnnouncementNotFound
}
activeSubs
,
err
:=
s
.
userSubRepo
.
ListActiveByUserID
(
ctx
,
userID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"list active subscriptions: %w"
,
err
)
}
activeGroupIDs
:=
make
(
map
[
int64
]
struct
{},
len
(
activeSubs
))
for
i
:=
range
activeSubs
{
activeGroupIDs
[
activeSubs
[
i
]
.
GroupID
]
=
struct
{}{}
}
if
!
a
.
Targeting
.
Matches
(
user
.
Balance
,
activeGroupIDs
)
{
return
ErrAnnouncementNotFound
}
if
err
:=
s
.
readRepo
.
MarkRead
(
ctx
,
announcementID
,
userID
,
now
);
err
!=
nil
{
return
fmt
.
Errorf
(
"mark read: %w"
,
err
)
}
return
nil
}
func
(
s
*
AnnouncementService
)
ListUserReadStatus
(
ctx
context
.
Context
,
announcementID
int64
,
params
pagination
.
PaginationParams
,
search
string
,
)
([]
AnnouncementUserReadStatus
,
*
pagination
.
PaginationResult
,
error
)
{
ann
,
err
:=
s
.
announcementRepo
.
GetByID
(
ctx
,
announcementID
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
filters
:=
UserListFilters
{
Search
:
strings
.
TrimSpace
(
search
),
}
users
,
page
,
err
:=
s
.
userRepo
.
ListWithFilters
(
ctx
,
params
,
filters
)
if
err
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"list users: %w"
,
err
)
}
userIDs
:=
make
([]
int64
,
0
,
len
(
users
))
for
i
:=
range
users
{
userIDs
=
append
(
userIDs
,
users
[
i
]
.
ID
)
}
readMap
,
err
:=
s
.
readRepo
.
GetReadMapByUsers
(
ctx
,
announcementID
,
userIDs
)
if
err
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"get read map: %w"
,
err
)
}
out
:=
make
([]
AnnouncementUserReadStatus
,
0
,
len
(
users
))
for
i
:=
range
users
{
u
:=
users
[
i
]
subs
,
err
:=
s
.
userSubRepo
.
ListActiveByUserID
(
ctx
,
u
.
ID
)
if
err
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"list active subscriptions: %w"
,
err
)
}
activeGroupIDs
:=
make
(
map
[
int64
]
struct
{},
len
(
subs
))
for
j
:=
range
subs
{
activeGroupIDs
[
subs
[
j
]
.
GroupID
]
=
struct
{}{}
}
readAt
,
ok
:=
readMap
[
u
.
ID
]
var
ptr
*
time
.
Time
if
ok
{
t
:=
readAt
ptr
=
&
t
}
out
=
append
(
out
,
AnnouncementUserReadStatus
{
UserID
:
u
.
ID
,
Email
:
u
.
Email
,
Username
:
u
.
Username
,
Balance
:
u
.
Balance
,
Eligible
:
domain
.
AnnouncementTargeting
(
ann
.
Targeting
)
.
Matches
(
u
.
Balance
,
activeGroupIDs
),
ReadAt
:
ptr
,
})
}
return
out
,
page
,
nil
}
func
isValidAnnouncementStatus
(
status
string
)
bool
{
switch
status
{
case
AnnouncementStatusDraft
,
AnnouncementStatusActive
,
AnnouncementStatusArchived
:
return
true
default
:
return
false
}
}
backend/internal/service/announcement_targeting_test.go
0 → 100644
View file @
d3062b2e
package
service
import
(
"testing"
"github.com/stretchr/testify/require"
)
func
TestAnnouncementTargeting_Matches_EmptyMatchesAll
(
t
*
testing
.
T
)
{
var
targeting
AnnouncementTargeting
require
.
True
(
t
,
targeting
.
Matches
(
0
,
nil
))
require
.
True
(
t
,
targeting
.
Matches
(
123.45
,
map
[
int64
]
struct
{}{
1
:
{}}))
}
func
TestAnnouncementTargeting_NormalizeAndValidate_RejectsEmptyGroup
(
t
*
testing
.
T
)
{
targeting
:=
AnnouncementTargeting
{
AnyOf
:
[]
AnnouncementConditionGroup
{
{
AllOf
:
nil
},
},
}
_
,
err
:=
targeting
.
NormalizeAndValidate
()
require
.
Error
(
t
,
err
)
require
.
ErrorIs
(
t
,
err
,
ErrAnnouncementInvalidTarget
)
}
func
TestAnnouncementTargeting_NormalizeAndValidate_RejectsInvalidCondition
(
t
*
testing
.
T
)
{
targeting
:=
AnnouncementTargeting
{
AnyOf
:
[]
AnnouncementConditionGroup
{
{
AllOf
:
[]
AnnouncementCondition
{
{
Type
:
"balance"
,
Operator
:
"between"
,
Value
:
10
},
},
},
},
}
_
,
err
:=
targeting
.
NormalizeAndValidate
()
require
.
Error
(
t
,
err
)
require
.
ErrorIs
(
t
,
err
,
ErrAnnouncementInvalidTarget
)
}
func
TestAnnouncementTargeting_Matches_AndOrSemantics
(
t
*
testing
.
T
)
{
targeting
:=
AnnouncementTargeting
{
AnyOf
:
[]
AnnouncementConditionGroup
{
{
AllOf
:
[]
AnnouncementCondition
{
{
Type
:
AnnouncementConditionTypeBalance
,
Operator
:
AnnouncementOperatorGTE
,
Value
:
100
},
{
Type
:
AnnouncementConditionTypeSubscription
,
Operator
:
AnnouncementOperatorIn
,
GroupIDs
:
[]
int64
{
10
}},
},
},
{
AllOf
:
[]
AnnouncementCondition
{
{
Type
:
AnnouncementConditionTypeBalance
,
Operator
:
AnnouncementOperatorLT
,
Value
:
5
},
},
},
},
}
// 命中第 2 组(balance < 5)
require
.
True
(
t
,
targeting
.
Matches
(
4.99
,
nil
))
require
.
False
(
t
,
targeting
.
Matches
(
5
,
nil
))
// 命中第 1 组(balance >= 100 AND 订阅 in [10])
require
.
False
(
t
,
targeting
.
Matches
(
100
,
map
[
int64
]
struct
{}{}))
require
.
False
(
t
,
targeting
.
Matches
(
99.9
,
map
[
int64
]
struct
{}{
10
:
{}}))
require
.
True
(
t
,
targeting
.
Matches
(
100
,
map
[
int64
]
struct
{}{
10
:
{}}))
}
backend/internal/service/domain_constants.go
View file @
d3062b2e
package
service
import
"github.com/Wei-Shaw/sub2api/internal/domain"
// Status constants
const
(
StatusActive
=
"a
ctive
"
StatusDisabled
=
"
disabled
"
StatusError
=
"e
rror
"
StatusUnused
=
"u
nused
"
StatusUsed
=
"u
sed
"
StatusExpired
=
"e
xpired
"
StatusActive
=
domain
.
StatusA
ctive
StatusDisabled
=
d
omain
.
StatusD
isabled
StatusError
=
domain
.
StatusE
rror
StatusUnused
=
domain
.
StatusU
nused
StatusUsed
=
domain
.
StatusU
sed
StatusExpired
=
domain
.
StatusE
xpired
)
// Role constants
const
(
RoleAdmin
=
"a
dmin
"
RoleUser
=
"u
ser
"
RoleAdmin
=
domain
.
RoleA
dmin
RoleUser
=
domain
.
RoleU
ser
)
// Platform constants
const
(
PlatformAnthropic
=
"a
nthropic
"
PlatformOpenAI
=
"openai"
PlatformGemini
=
"g
emini
"
PlatformAntigravity
=
"a
ntigravity
"
PlatformAnthropic
=
domain
.
PlatformA
nthropic
PlatformOpenAI
=
domain
.
PlatformOpenAI
PlatformGemini
=
domain
.
PlatformG
emini
PlatformAntigravity
=
domain
.
PlatformA
ntigravity
)
// Account type constants
const
(
AccountTypeOAuth
=
"oa
uth
"
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
"s
etup
-t
oken
"
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
"apikey"
// API Key类型账号
AccountTypeOAuth
=
domain
.
AccountTypeOA
uth
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
domain
.
AccountTypeS
etup
T
oken
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
domain
.
AccountTypeAPIKey
// API Key类型账号
)
// Redeem type constants
const
(
RedeemTypeBalance
=
"b
alance
"
RedeemTypeConcurrency
=
"c
oncurrency
"
RedeemTypeSubscription
=
"s
ubscription
"
RedeemTypeBalance
=
domain
.
RedeemTypeB
alance
RedeemTypeConcurrency
=
domain
.
RedeemTypeC
oncurrency
RedeemTypeSubscription
=
domain
.
RedeemTypeS
ubscription
)
// PromoCode status constants
const
(
PromoCodeStatusActive
=
"a
ctive
"
PromoCodeStatusDisabled
=
"
disabled
"
PromoCodeStatusActive
=
domain
.
PromoCodeStatusA
ctive
PromoCodeStatusDisabled
=
d
omain
.
PromoCodeStatusD
isabled
)
// Admin adjustment type constants
const
(
AdjustmentTypeAdminBalance
=
"a
dmin
_b
alance
"
// 管理员调整余额
AdjustmentTypeAdminConcurrency
=
"a
dmin
_c
oncurrency
"
// 管理员调整并发数
AdjustmentTypeAdminBalance
=
domain
.
AdjustmentTypeA
dmin
B
alance
// 管理员调整余额
AdjustmentTypeAdminConcurrency
=
domain
.
AdjustmentTypeA
dmin
C
oncurrency
// 管理员调整并发数
)
// Group subscription type constants
const
(
SubscriptionTypeStandard
=
"s
tandard
"
// 标准计费模式(按余额扣费)
SubscriptionTypeSubscription
=
"s
ubscription
"
// 订阅模式(按限额控制)
SubscriptionTypeStandard
=
domain
.
SubscriptionTypeS
tandard
// 标准计费模式(按余额扣费)
SubscriptionTypeSubscription
=
domain
.
SubscriptionTypeS
ubscription
// 订阅模式(按限额控制)
)
// Subscription status constants
const
(
SubscriptionStatusActive
=
"a
ctive
"
SubscriptionStatusExpired
=
"e
xpired
"
SubscriptionStatusSuspended
=
"s
uspended
"
SubscriptionStatusActive
=
domain
.
SubscriptionStatusA
ctive
SubscriptionStatusExpired
=
domain
.
SubscriptionStatusE
xpired
SubscriptionStatusSuspended
=
domain
.
SubscriptionStatusS
uspended
)
// LinuxDoConnectSyntheticEmailDomain 是 LinuxDo Connect 用户的合成邮箱后缀(RFC 保留域名)。
...
...
backend/internal/service/wire.go
View file @
d3062b2e
...
...
@@ -226,6 +226,7 @@ var ProviderSet = wire.NewSet(
ProvidePricingService
,
NewBillingService
,
NewBillingCacheService
,
NewAnnouncementService
,
NewAdminService
,
NewGatewayService
,
NewOpenAIGatewayService
,
...
...
backend/migrations/045_add_announcements.sql
0 → 100644
View file @
d3062b2e
-- 创建公告表
CREATE
TABLE
IF
NOT
EXISTS
announcements
(
id
BIGSERIAL
PRIMARY
KEY
,
title
VARCHAR
(
200
)
NOT
NULL
,
content
TEXT
NOT
NULL
,
status
VARCHAR
(
20
)
NOT
NULL
DEFAULT
'draft'
,
targeting
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
,
starts_at
TIMESTAMPTZ
DEFAULT
NULL
,
ends_at
TIMESTAMPTZ
DEFAULT
NULL
,
created_by
BIGINT
DEFAULT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
updated_by
BIGINT
DEFAULT
NULL
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
-- 公告已读表
CREATE
TABLE
IF
NOT
EXISTS
announcement_reads
(
id
BIGSERIAL
PRIMARY
KEY
,
announcement_id
BIGINT
NOT
NULL
REFERENCES
announcements
(
id
)
ON
DELETE
CASCADE
,
user_id
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
CASCADE
,
read_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
UNIQUE
(
announcement_id
,
user_id
)
);
-- 索引
CREATE
INDEX
IF
NOT
EXISTS
idx_announcements_status
ON
announcements
(
status
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcements_starts_at
ON
announcements
(
starts_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcements_ends_at
ON
announcements
(
ends_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcements_created_at
ON
announcements
(
created_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcement_reads_announcement_id
ON
announcement_reads
(
announcement_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcement_reads_user_id
ON
announcement_reads
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_announcement_reads_read_at
ON
announcement_reads
(
read_at
);
COMMENT
ON
TABLE
announcements
IS
'系统公告'
;
COMMENT
ON
COLUMN
announcements
.
status
IS
'状态: draft, active, archived'
;
COMMENT
ON
COLUMN
announcements
.
targeting
IS
'展示条件(JSON 规则)'
;
COMMENT
ON
COLUMN
announcements
.
starts_at
IS
'开始展示时间(为空表示立即生效)'
;
COMMENT
ON
COLUMN
announcements
.
ends_at
IS
'结束展示时间(为空表示永久生效)'
;
COMMENT
ON
TABLE
announcement_reads
IS
'公告已读记录'
;
COMMENT
ON
COLUMN
announcement_reads
.
read_at
IS
'用户首次已读时间'
;
frontend/src/api/admin/announcements.ts
0 → 100644
View file @
d3062b2e
/**
* Admin Announcements API endpoints
*/
import
{
apiClient
}
from
'
../client
'
import
type
{
Announcement
,
AnnouncementUserReadStatus
,
BasePaginationResponse
,
CreateAnnouncementRequest
,
UpdateAnnouncementRequest
}
from
'
@/types
'
export
async
function
list
(
page
:
number
=
1
,
pageSize
:
number
=
20
,
filters
?:
{
status
?:
string
search
?:
string
}
):
Promise
<
BasePaginationResponse
<
Announcement
>>
{
const
{
data
}
=
await
apiClient
.
get
<
BasePaginationResponse
<
Announcement
>>
(
'
/admin/announcements
'
,
{
params
:
{
page
,
page_size
:
pageSize
,
...
filters
}
})
return
data
}
export
async
function
getById
(
id
:
number
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
get
<
Announcement
>
(
`/admin/announcements/
${
id
}
`
)
return
data
}
export
async
function
create
(
request
:
CreateAnnouncementRequest
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
post
<
Announcement
>
(
'
/admin/announcements
'
,
request
)
return
data
}
export
async
function
update
(
id
:
number
,
request
:
UpdateAnnouncementRequest
):
Promise
<
Announcement
>
{
const
{
data
}
=
await
apiClient
.
put
<
Announcement
>
(
`/admin/announcements/
${
id
}
`
,
request
)
return
data
}
export
async
function
deleteAnnouncement
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
delete
<
{
message
:
string
}
>
(
`/admin/announcements/
${
id
}
`
)
return
data
}
export
async
function
getReadStatus
(
id
:
number
,
page
:
number
=
1
,
pageSize
:
number
=
20
,
search
:
string
=
''
):
Promise
<
BasePaginationResponse
<
AnnouncementUserReadStatus
>>
{
const
{
data
}
=
await
apiClient
.
get
<
BasePaginationResponse
<
AnnouncementUserReadStatus
>>
(
`/admin/announcements/
${
id
}
/read-status`
,
{
params
:
{
page
,
page_size
:
pageSize
,
search
}
}
)
return
data
}
const
announcementsAPI
=
{
list
,
getById
,
create
,
update
,
delete
:
deleteAnnouncement
,
getReadStatus
}
export
default
announcementsAPI
frontend/src/api/admin/index.ts
View file @
d3062b2e
...
...
@@ -10,6 +10,7 @@ import accountsAPI from './accounts'
import
proxiesAPI
from
'
./proxies
'
import
redeemAPI
from
'
./redeem
'
import
promoAPI
from
'
./promo
'
import
announcementsAPI
from
'
./announcements
'
import
settingsAPI
from
'
./settings
'
import
systemAPI
from
'
./system
'
import
subscriptionsAPI
from
'
./subscriptions
'
...
...
@@ -30,6 +31,7 @@ export const adminAPI = {
proxies
:
proxiesAPI
,
redeem
:
redeemAPI
,
promo
:
promoAPI
,
announcements
:
announcementsAPI
,
settings
:
settingsAPI
,
system
:
systemAPI
,
subscriptions
:
subscriptionsAPI
,
...
...
@@ -48,6 +50,7 @@ export {
proxiesAPI
,
redeemAPI
,
promoAPI
,
announcementsAPI
,
settingsAPI
,
systemAPI
,
subscriptionsAPI
,
...
...
frontend/src/api/announcements.ts
0 → 100644
View file @
d3062b2e
/**
* User Announcements API endpoints
*/
import
{
apiClient
}
from
'
./client
'
import
type
{
UserAnnouncement
}
from
'
@/types
'
export
async
function
list
(
unreadOnly
:
boolean
=
false
):
Promise
<
UserAnnouncement
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
UserAnnouncement
[]
>
(
'
/announcements
'
,
{
params
:
unreadOnly
?
{
unread_only
:
1
}
:
{}
})
return
data
}
export
async
function
markRead
(
id
:
number
):
Promise
<
{
message
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
message
:
string
}
>
(
`/announcements/
${
id
}
/read`
)
return
data
}
const
announcementsAPI
=
{
list
,
markRead
}
export
default
announcementsAPI
Prev
1
2
3
4
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment