cyi-box: Memphis-style toolbox admin platform

Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway
(Epay-compatible + CDK stock), license/card system (per-app appid, domain
binding), AI memory bank with built-in MCP server (/mcp, FTS5 search,
signed read links), server SSH monitor + terminal + SFTP, proxy pool with
protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL
autocomplete + import/export), disk share, vault (AES-GCM + TOTP),
bookmarks, dev tools.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-01 01:58:47 +08:00
co-authored by Devin
commit 834ecacdb9
326 changed files with 27813 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/cyi-cc/fun"
"golang.org/x/crypto/bcrypt"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// 登录态:24h 滑动过期;距上次续期超过 2h 的请求自动把有效期顺延回 24h
const sessionTTL = 24 * time.Hour
const sessionRenewAfter = 2 * time.Hour
type AuthSvc struct {
fun.Ctx
Store *store.Store
}
// Login 用户名+密码 → 签发会话 token(前端存 localStorage,随 state 透传)
func (s *AuthSvc) Login(d dto.LoginDto) (dto.SessionView, error) {
name := strings.TrimSpace(d.Name)
if name == "" || d.Password == "" {
return dto.SessionView{}, fun.Error(4001, "请输入用户名和密码")
}
user, err := s.Store.GetUserByName(name)
if errors.Is(err, store.ErrNotFound) {
return dto.SessionView{}, fun.Error(4001, "用户名或密码错误")
}
if err != nil {
return dto.SessionView{}, fmt.Errorf("auth: query user failed: %w", err)
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(d.Password)) != nil {
return dto.SessionView{}, fun.Error(4001, "用户名或密码错误")
}
if user.Status != "active" {
return dto.SessionView{}, fun.Error(4003, "账号已被禁用")
}
token, err := newToken()
if err != nil {
return dto.SessionView{}, err
}
if err := s.Store.CreateSession(token, user.Id, sessionTTL); err != nil {
return dto.SessionView{}, fmt.Errorf("auth: create session failed: %w", err)
}
log.Printf("auth: user %q logged in from %s", user.Name, s.Ip)
view := userView(user)
return dto.SessionView{User: &view, Token: &token}, nil
}
// Me 返回当前登录用户(不重复签发 token)
func (s *AuthSvc) Me() (dto.SessionView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.SessionView{}, err
}
view := userView(su.User)
return dto.SessionView{User: &view}, nil
}
// Logout 幂等:删掉当前会话即可
func (s *AuthSvc) Logout() (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{Message: "已退出"}, nil
}
if err := s.Store.DeleteSession(su.Token); err != nil {
return dto.OkView{}, fmt.Errorf("auth: logout failed: %w", err)
}
return dto.OkView{Message: "已退出"}, nil
}
// ChangePassword 校验旧密码 → 改密 → 吊销其他会话(当前会话保留)
func (s *AuthSvc) ChangePassword(d dto.ChangePasswordDto) (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{}, err
}
if len(d.NewPassword) < 6 {
return dto.OkView{}, fun.Error(4001, "新密码至少 6 位")
}
if bcrypt.CompareHashAndPassword([]byte(su.User.PasswordHash), []byte(d.OldPassword)) != nil {
return dto.OkView{}, fun.Error(4001, "旧密码错误")
}
hash, err := bcrypt.GenerateFromPassword([]byte(d.NewPassword), bcrypt.DefaultCost)
if err != nil {
return dto.OkView{}, err
}
h := string(hash)
if err := s.Store.UpdateUser(su.User.Id, su.User.Name, su.User.Role, su.User.Status, &h); err != nil {
return dto.OkView{}, fmt.Errorf("auth: update password failed: %w", err)
}
if err := s.Store.DeleteOtherSessions(su.User.Id, su.Token); err != nil {
return dto.OkView{}, fmt.Errorf("auth: revoke sessions failed: %w", err)
}
return dto.OkView{Message: "密码已修改"}, nil
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func userView(u store.User) dto.UserView {
return dto.UserView{
Id: u.Id,
Name: u.Name,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
}
}
+152
View File
@@ -0,0 +1,152 @@
package service
import (
"errors"
"github.com/cyi-cc/fun"
"github.com/valyala/fasthttp"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// 认证统一业务错误码:前端响应拦截器据此清理登录态并跳登录页
const authErrCode = 4011
type authPolicy uint8
const (
policyDeny authPolicy = iota
policyPublic
policyAuthenticated
policyAdmin
)
// endpointPolicies 显式端点策略表:缺省拒绝,新端点必须登记
var endpointPolicies = map[string]authPolicy{
"AuthSvc.Login": policyPublic,
"AuthSvc.Me": policyAuthenticated,
"AuthSvc.Logout": policyAuthenticated,
"AuthSvc.ChangePassword": policyAuthenticated,
"UserSvc.ListUsers": policyAdmin,
"UserSvc.SaveUser": policyAdmin,
"UserSvc.DeleteUser": policyAdmin,
"DashboardSvc.Stats": policyAuthenticated,
"ToolSvc.List": policyAuthenticated,
"ToolSvc.AdminList": policyAdmin,
"ToolSvc.SaveTool": policyAdmin,
"ToolSvc.DeleteTool": policyAdmin,
"VaultSvc.List": policyAuthenticated,
"VaultSvc.Save": policyAuthenticated,
"VaultSvc.Reveal": policyAuthenticated,
"VaultSvc.Totp": policyAuthenticated,
"VaultSvc.Delete": policyAuthenticated,
"BookmarkSvc.List": policyAuthenticated,
"BookmarkSvc.Save": policyAuthenticated,
"BookmarkSvc.Delete": policyAuthenticated,
"ServerSvc.List": policyAdmin,
"ServerSvc.Save": policyAdmin,
"ServerSvc.Delete": policyAdmin,
"ServerSvc.Metrics": policyAdmin,
"ServerSvc.SftpList": policyAdmin,
"ServerSvc.SftpMkdir": policyAdmin,
"ServerSvc.SftpRemove": policyAdmin,
"ServerSvc.SftpRename": policyAdmin,
"DbMgrSvc.List": policyAdmin,
"DbMgrSvc.Save": policyAdmin,
"DbMgrSvc.Delete": policyAdmin,
"DbMgrSvc.Test": policyAdmin,
"DbMgrSvc.Databases": policyAdmin,
"DbMgrSvc.Tables": policyAdmin,
"DbMgrSvc.Query": policyAdmin,
"WebSvc.Fetch": policyAuthenticated,
"DiskSvc.List": policyAuthenticated,
"DiskSvc.DeleteFile": policyAuthenticated,
"DiskSvc.CreateShare": policyAuthenticated,
"DiskSvc.ListShares": policyAuthenticated,
"DiskSvc.DeleteShare": policyAuthenticated,
"ProxySvc.List": policyAuthenticated,
"ProxySvc.Regions": policyAuthenticated,
"ProxySvc.Syncs": policyAdmin,
"ProxySvc.Stats": policyAuthenticated,
"ProxySvc.SyncNow": policyAdmin,
"ProxySvc.CheckNow": policyAdmin,
"ProxySvc.ListKeys": policyAdmin,
"ProxySvc.CreateKey": policyAdmin,
"ProxySvc.DeleteKey": policyAdmin,
"SettingSvc.Get": policyAuthenticated,
"SettingSvc.Set": policyAdmin,
"PaySvc.List": policyAdmin,
"PaySvc.Stats": policyAdmin,
"PaySvc.UpstreamStatus": policyAdmin,
"PaySvc.SaveUpstream": policyAdmin,
"PaySvc.TestPay": policyAdmin,
"PaySvc.CashierInfo": policyPublic,
"PaySvc.CashierStatus": policyPublic,
"PaySvc.Watch": policyPublic,
"LicenseSvc.Apps": policyAdmin,
"LicenseSvc.SaveApp": policyAdmin,
"LicenseSvc.DeleteApp": policyAdmin,
"LicenseSvc.List": policyAdmin,
"LicenseSvc.Stats": policyAdmin,
"LicenseSvc.Generate": policyAdmin,
"LicenseSvc.Delete": policyAdmin,
"MemorySvc.Projects": policyAdmin,
"MemorySvc.List": policyAdmin,
"MemorySvc.Get": policyAdmin,
"MemorySvc.Save": policyAdmin,
"MemorySvc.Delete": policyAdmin,
"MemorySvc.McpInfo": policyAdmin,
"MemorySvc.ResetToken": policyAdmin,
}
// AuthGuard 唯一 RPC 鉴权边界:token 由前端请求拦截器写入 ctx.State["token"],
// SQLite 会话表校验有效性,命中后用户经 SetUserValue 传给服务层
type AuthGuard struct {
Store *store.Store
}
func (g *AuthGuard) Guard(ctx fun.Ctx) error {
policy := endpointPolicies[ctx.ServiceName+"."+ctx.MethodName]
switch policy {
case policyPublic:
return nil
case policyDeny:
return fun.Error(4003, "接口未登记鉴权策略")
}
if ctx.RequestCtx == nil {
return errors.New("auth guard: missing request context")
}
token := ctx.State["token"]
if token == "" {
return fun.Error(authErrCode, "请先登录")
}
su, err := g.Store.GetSessionUser(token, sessionTTL, sessionRenewAfter)
if errors.Is(err, store.ErrNotFound) {
return fun.Error(authErrCode, "登录已失效,请重新登录")
}
if err != nil {
return err
}
if su.User.Status != "active" {
return fun.Error(4003, "账号已被禁用")
}
if policy == policyAdmin && su.User.Role != "admin" {
return fun.Error(4003, "仅管理员可访问")
}
ctx.RequestCtx.SetUserValue(sessionKey{}, su)
return nil
}
type sessionKey struct{}
// sessionFrom 取 Guard 校验过的会话;策略表保证进入服务层时必然已设置
func sessionFrom(ctx *fasthttp.RequestCtx) (store.SessionUser, error) {
if ctx == nil {
return store.SessionUser{}, errors.New("auth session: missing request context")
}
su, ok := ctx.UserValue(sessionKey{}).(store.SessionUser)
if !ok || su.User.Id == 0 {
return store.SessionUser{}, errors.New("auth session: validated session missing")
}
return su, nil
}
+91
View File
@@ -0,0 +1,91 @@
package service
import (
"errors"
"fmt"
"net/url"
"strings"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// BookmarkSvc 个人书签:列表驱动侧边栏快捷入口与书签页
type BookmarkSvc struct {
fun.Ctx
Store *store.Store
}
func (s *BookmarkSvc) List() ([]dto.BookmarkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return nil, err
}
items, err := s.Store.ListBookmarks(su.User.Id)
if err != nil {
return nil, fmt.Errorf("bookmark: list failed: %w", err)
}
views := make([]dto.BookmarkView, 0, len(items))
for _, b := range items {
views = append(views, bookmarkView(b))
}
return views, nil
}
func (s *BookmarkSvc) Save(d dto.SaveBookmarkDto) (dto.BookmarkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.BookmarkView{}, err
}
title := strings.TrimSpace(d.Title)
raw := strings.TrimSpace(d.Url)
if title == "" || len(title) > 64 {
return dto.BookmarkView{}, fun.Error(4001, "名称必填且不超过 64 字符")
}
if raw != "" && !strings.Contains(raw, "://") {
raw = "https://" + raw // 裸域名自动补协议
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" ||
(u.Scheme != "http" && u.Scheme != "https") {
return dto.BookmarkView{}, fun.Error(4001, "网址无效,需 http(s) 地址")
}
b := store.Bookmark{UserID: su.User.Id, Title: title, Url: raw}
if d.Id != nil {
b.Id = *d.Id
}
if d.Icon != nil {
b.Icon = strings.TrimSpace(*d.Icon)
}
if d.Sort != nil {
b.Sort = *d.Sort
}
if err := s.Store.SaveBookmark(&b); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.BookmarkView{}, fun.Error(4001, "书签不存在")
}
return dto.BookmarkView{}, fmt.Errorf("bookmark: save failed: %w", err)
}
return bookmarkView(b), nil
}
func (s *BookmarkSvc) Delete(d dto.BookmarkIdDto) (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{}, err
}
if err := s.Store.DeleteBookmark(su.User.Id, d.Id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "书签不存在")
}
return dto.OkView{}, fmt.Errorf("bookmark: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
func bookmarkView(b store.Bookmark) dto.BookmarkView {
return dto.BookmarkView{Id: b.Id, Title: b.Title, Url: b.Url, Icon: b.Icon, Sort: b.Sort}
}
+114
View File
@@ -0,0 +1,114 @@
package service
import (
"context"
"fmt"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/db"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
var bootTime = time.Now()
type DashboardSvc struct {
fun.Ctx
Store *store.Store
}
// Stats 仪表盘聚合:会话概况 + 各业务模块计数 + 收入趋势 + 最近动态
func (s *DashboardSvc) Stats() (dto.StatsView, error) {
ctx := context.Background()
q := s.Store.Q()
now := time.Now()
view := dto.StatsView{UptimeSeconds: int64(time.Since(bootTime).Seconds())}
// 会话概况
var err error
if view.TotalUsers, err = s.Store.CountUsers(); err != nil {
return view, fmt.Errorf("dashboard: count users failed: %w", err)
}
if view.ActiveSessions, err = s.Store.CountActiveSessions(); err != nil {
return view, fmt.Errorf("dashboard: count sessions failed: %w", err)
}
if view.TodayLogins, err = s.Store.CountTodayLogins(); err != nil {
return view, fmt.Errorf("dashboard: count today logins failed: %w", err)
}
const days = 14
trend, err := s.Store.LoginTrend(days)
if err != nil {
return view, fmt.Errorf("dashboard: login trend failed: %w", err)
}
view.LoginTrend = make([]dto.TrendPoint, 0, days)
for i := days - 1; i >= 0; i-- {
day := now.AddDate(0, 0, -int(i)).Format("2006-01-02")
view.LoginTrend = append(view.LoginTrend, dto.TrendPoint{Day: day, Count: trend[day]})
}
// 业务模块计数
view.Servers, _ = s.Store.CountServers()
view.Proxies, _ = q.CountProxiesAll(ctx)
view.AliveProxies, _ = s.Store.CountAliveProxies()
view.LicenseApps, _ = q.CountLicenseApps(ctx)
view.Dbconns, _ = q.CountDbconns(ctx)
view.Files, _ = q.CountFilesAll(ctx)
view.Memories, _ = q.CountMemories(ctx)
if ps, err := s.Store.MemoryProjects(); err == nil {
view.MemoryProjects = int64(len(ps))
}
if ls, err := q.LicenseCardStatsAll(ctx, now.Unix()); err == nil {
view.LicenseCards, view.LicenseActive = ls.Total, anyI64(ls.Active)
}
if su, err := sessionFrom(s.RequestCtx); err == nil {
view.Bookmarks, _ = q.CountBookmarksByUser(ctx, su.User.Id)
view.VaultItems, _ = q.CountVaultByUser(ctx, su.User.Id)
}
// 支付:今日 + 累计 + 14 天收入趋势 + 最近成交
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
if st, err := q.PayOrderStats(ctx, dayStart); err == nil {
view.TodayOrders, view.TodayMoney = st.Cnt, anyI64(st.Money)
}
if st, err := q.PayOrderStats(ctx, 0); err == nil {
view.TotalMoney, view.PendingOrders = anyI64(st.Money), anyI64(st.Pending)
}
daily := map[string]db.PayDailyPaidRow{}
if rows, err := q.PayDailyPaid(ctx, dayStart-int64((days-1)*86400)); err == nil {
for _, r := range rows {
daily[r.Day] = r
}
}
view.PayTrend = make([]dto.PayTrendPoint, 0, days)
for i := days - 1; i >= 0; i-- {
t := now.AddDate(0, 0, -int(i))
p := dto.PayTrendPoint{Day: t.Format("2006-01-02")}
if r, ok := daily[p.Day]; ok {
p.Money, p.Count = anyI64(r.Money), r.Cnt
}
view.PayTrend = append(view.PayTrend, p)
}
if rows, err := q.RecentPaidOrders(ctx, 6); err == nil {
view.RecentOrders = make([]dto.RecentOrderView, 0, len(rows))
for _, r := range rows {
subject := r.Subject
if subject == "" {
subject = r.TradeNo
}
view.RecentOrders = append(view.RecentOrders, dto.RecentOrderView{
TradeNo: r.TradeNo, Subject: subject, Money: r.Money, PaidAt: r.PaidAt,
})
}
}
if ms, err := s.Store.ListMemories("", "", 5); err == nil {
view.RecentMemories = make([]dto.RecentMemoryView, 0, len(ms))
for _, m := range ms {
view.RecentMemories = append(view.RecentMemories, dto.RecentMemoryView{
Key: m.Key, Project: m.Project, UpdatedAt: m.UpdatedAt,
})
}
}
return view, nil
}
+242
View File
@@ -0,0 +1,242 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dbx"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// DbMgrSvc 通用数据库管理器:连接密码 AES-GCM 落库,全部端点仅管理员;
// 查询固定 20s 超时 + 行数封顶,防止一条烂 SQL 拖死进程
type DbMgrSvc struct {
fun.Ctx
Store *store.Store
Cfg *config.Config `fun:"auto"`
}
func (s *DbMgrSvc) List() ([]dto.DbconnView, error) {
items, err := s.Store.ListDbconns()
if err != nil {
return nil, fmt.Errorf("dbm: list failed: %w", err)
}
views := make([]dto.DbconnView, 0, len(items))
for _, e := range items {
views = append(views, dbconnView(e))
}
return views, nil
}
func (s *DbMgrSvc) Save(d dto.SaveDbconnDto) (dto.DbconnView, error) {
name := strings.TrimSpace(d.Name)
engine := strings.TrimSpace(d.Engine)
switch engine {
case "mysql", "postgres", "sqlite":
default:
return dto.DbconnView{}, fun.Error(4001, "引擎只支持 mysql / postgres / sqlite")
}
if name == "" || len(name) > 64 {
return dto.DbconnView{}, fun.Error(4001, "名称必填且不超过 64 字符")
}
e := store.Dbconn{Name: name, Engine: engine}
if d.Id != nil {
old, err := s.Store.GetDbconn(*d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.DbconnView{}, fun.Error(4001, "连接不存在")
}
if err != nil {
return dto.DbconnView{}, err
}
e = old
e.Name, e.Engine = name, engine
}
if d.Host != nil {
e.Host = strings.TrimSpace(*d.Host)
}
if d.Port != nil {
e.Port = *d.Port
}
if d.Username != nil {
e.Username = strings.TrimSpace(*d.Username)
}
if d.Database != nil {
e.Database = strings.TrimSpace(*d.Database)
}
if d.Params != nil {
e.Params = strings.TrimSpace(*d.Params)
}
if d.Password != nil {
pw := *d.Password
if pw == "" {
e.PasswordEnc = ""
} else {
enc, err := vault.Encrypt(s.Cfg.VaultKey, pw)
if err != nil {
return dto.DbconnView{}, err
}
e.PasswordEnc = enc
}
}
if e.Engine != "sqlite" && e.Host == "" {
return dto.DbconnView{}, fun.Error(4001, "主机地址必填")
}
if e.Engine == "sqlite" && e.Database == "" {
return dto.DbconnView{}, fun.Error(4001, "sqlite 需在「数据库」填文件路径")
}
if err := s.Store.SaveDbconn(&e); err != nil {
return dto.DbconnView{}, fmt.Errorf("dbm: save failed: %w", err)
}
return dbconnView(e), nil
}
func (s *DbMgrSvc) Delete(d dto.DbconnIdDto) (dto.OkView, error) {
if err := s.Store.DeleteDbconn(d.Id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "连接不存在")
}
return dto.OkView{}, fmt.Errorf("dbm: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
// openConn 解密码 + 建连 + ping
func openConn(st *store.Store, key []byte, id int64, database string) (*sql.DB, store.Dbconn, error) {
e, err := st.GetDbconn(id)
if errors.Is(err, store.ErrNotFound) {
return nil, e, fun.Error(4001, "连接不存在")
}
if err != nil {
return nil, e, err
}
pw := ""
if e.PasswordEnc != "" {
pw, err = vault.Decrypt(key, e.PasswordEnc)
if err != nil {
return nil, e, fmt.Errorf("dbm: decrypt password failed: %w", err)
}
}
d, err := dbx.Open(dbx.Conn{
Engine: e.Engine, Host: e.Host, Port: e.Port, Username: e.Username,
Password: pw, Database: e.Database, Params: e.Params,
}, database)
return d, e, err
}
func (s *DbMgrSvc) open(id int64, database string) (*sql.DB, store.Dbconn, error) {
return openConn(s.Store, s.Cfg.VaultKey, id, database)
}
// Test 连通性测试
func (s *DbMgrSvc) Test(d dto.DbconnIdDto) (dto.OkView, error) {
c, _, err := s.open(d.Id, "")
if err != nil {
return dto.OkView{}, fun.Error(4001, err.Error())
}
defer c.Close()
return dto.OkView{Message: "连接成功"}, nil
}
func (s *DbMgrSvc) Databases(d dto.DbScopeDto) (dto.DbDatabasesView, error) {
c, e, err := s.open(d.Id, "")
if err != nil {
return dto.DbDatabasesView{}, err
}
defer c.Close()
names, err := dbx.Databases(c, e.Engine)
if err != nil {
return dto.DbDatabasesView{}, fmt.Errorf("dbm: list databases failed: %w", err)
}
return dto.DbDatabasesView{Names: names}, nil
}
func (s *DbMgrSvc) Tables(d dto.DbScopeDto) (dto.DbTablesView, error) {
db := ""
if d.Database != nil {
db = *d.Database
}
c, e, err := s.open(d.Id, db)
if err != nil {
return dto.DbTablesView{}, err
}
defer c.Close()
tables, cols, err := dbx.TablesAndColumns(c, e.Engine, db)
if err != nil {
return dto.DbTablesView{}, fmt.Errorf("dbm: list tables failed: %w", err)
}
v := dto.DbTablesView{Tables: []dto.DbTableView{}, Columns: []dto.DbColumnView{}}
for _, t := range tables {
v.Tables = append(v.Tables, dto.DbTableView{Name: t.Name, Type: t.Type})
}
for _, cl := range cols {
v.Columns = append(v.Columns, dto.DbColumnView{
Table: cl.Table, Name: cl.Name, Type: cl.Type, Pk: cl.Pk,
})
}
return v, nil
}
const dbQueryTimeout = 20 * time.Second
// Query 执行单条 SQL:SELECT 回结果集(JSON 行串),其余回 affected rows
func (s *DbMgrSvc) Query(d dto.DbQueryDto) (dto.DbQueryView, error) {
sqlText := strings.TrimSpace(d.Sql)
if sqlText == "" {
return dto.DbQueryView{}, fun.Error(4001, "SQL 不能为空")
}
db := ""
if d.Database != nil {
db = *d.Database
}
c, _, err := s.open(d.Id, db)
if err != nil {
return dto.DbQueryView{}, err
}
defer c.Close()
maxRows := int64(500)
if d.MaxRows != nil && *d.MaxRows > 0 {
maxRows = *d.MaxRows
}
ctx, cancel := context.WithTimeout(context.Background(), dbQueryTimeout)
defer cancel()
res, err := dbx.Query(ctx, c, sqlText, maxRows)
if err != nil {
return dto.DbQueryView{}, fun.Error(4002, "执行失败:"+err.Error())
}
rowsJson, _ := json.Marshal(res.Rows)
return dto.DbQueryView{
IsSelect: res.IsSelect,
Columns: res.Columns,
RowsJson: string(rowsJson),
RowCount: res.RowCount,
AffectedRows: res.AffectedRows,
Truncated: res.Truncated,
ElapsedMs: res.ElapsedMs,
}, nil
}
func dbconnView(e store.Dbconn) dto.DbconnView {
return dto.DbconnView{
Id: e.Id,
Name: e.Name,
Engine: e.Engine,
Host: e.Host,
Port: e.Port,
Username: e.Username,
Database: e.Database,
HasPassword: e.PasswordEnc != "",
CreatedAt: e.CreatedAt,
}
}
+394
View File
@@ -0,0 +1,394 @@
package service
import (
"bytes"
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/xuri/excelize/v2"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dbx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
const exportRowCap = 50000
// DbExportHandler POST /dbm/export 表单字段:id, db, sql, format(json|csv|xlsx)
func DbExportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
if err := routeAdmin(st, ctx); err != nil {
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
return nil
}
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
if id == 0 {
id, _ = strconv.ParseInt(rc.Data["id"], 10, 64)
}
dbName := rc.Data["db"]
sqlText := strings.TrimSpace(rc.Data["sql"])
format := rc.Data["format"]
if format == "" {
format = "json"
}
if id == 0 || sqlText == "" {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`)
return nil
}
c, _, err := openConn(st, cfg.VaultKey, id, dbName)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString("连接失败:" + err.Error())
return nil
}
defer c.Close()
qctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
res, err := dbx.Query(qctx, c, sqlText, exportRowCap)
if err != nil {
ctx.SetStatusCode(500)
ctx.SetBodyString("执行失败:" + err.Error())
return nil
}
if !res.IsSelect {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"非查询语句没有可导出的结果集"}`)
return nil
}
var body []byte
var ext, mime string
switch format {
case "xlsx":
body, err = toXLSX(res)
ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case "csv":
body, err = toCSV(res)
ext, mime = "csv", "text/csv; charset=utf-8"
default:
ext, mime = "json", "application/json"
body, err = toJSONRows(res)
}
if err != nil {
ctx.SetStatusCode(500)
ctx.SetBodyString("导出失败:" + err.Error())
return nil
}
fname := fmt.Sprintf("export_%d.%s", time.Now().Unix(), ext)
ctx.Response.Header.Set("Content-Disposition", "attachment; filename=\""+fname+"\"")
ctx.SetContentType(mime)
ctx.SetBody(body)
return nil
}
}
func toJSONRows(res *dbx.Result) ([]byte, error) {
list := make([]map[string]any, 0, len(res.Rows))
for _, row := range res.Rows {
obj := map[string]any{}
for i, col := range res.Columns {
obj[col] = row[i]
}
list = append(list, obj)
}
return json.MarshalIndent(list, "", " ")
}
func toCSV(res *dbx.Result) ([]byte, error) {
var buf bytes.Buffer
buf.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM,Excel 打开不乱码
w := csv.NewWriter(&buf)
if err := w.Write(res.Columns); err != nil {
return nil, err
}
for _, row := range res.Rows {
rec := make([]string, len(row))
for i, v := range row {
rec[i] = cellText(v)
}
if err := w.Write(rec); err != nil {
return nil, err
}
}
w.Flush()
return buf.Bytes(), w.Error()
}
func toXLSX(res *dbx.Result) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
sheet := "Sheet1"
for i, c := range res.Columns {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
if err := f.SetCellValue(sheet, cell, c); err != nil {
return nil, err
}
}
for ri, row := range res.Rows {
for ci, v := range row {
cell, _ := excelize.CoordinatesToCellName(ci+1, ri+2)
if v == nil {
continue
}
if err := f.SetCellValue(sheet, cell, v); err != nil {
return nil, err
}
}
}
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func cellText(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case bool:
if t {
return "1"
}
return "0"
default:
return fmt.Sprint(t)
}
}
// DbImportHandler POST /dbm/import multipart:file + 字段 id/db/table;
// 按扩展名解析 json(对象数组) / csv / xlsx(首行为表头),列名与表列交集后批量插入
func DbImportHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
if err := routeAdmin(st, ctx); err != nil {
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
return nil
}
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
dbName := string(ctx.QueryArgs().Peek("db"))
table := strings.TrimSpace(string(ctx.QueryArgs().Peek("table")))
if id == 0 || table == "" {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"参数不完整"}`)
return nil
}
fh, err := ctx.FormFile("file")
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`)
return nil
}
src, err := fh.Open()
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`)
return nil
}
defer src.Close()
raw, err := io.ReadAll(io.LimitReader(src, 64<<20))
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"读取文件失败"}`)
return nil
}
rows, err := parseImport(strings.ToLower(fh.Filename), raw)
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"` + err.Error() + `"}`)
return nil
}
if len(rows) == 0 {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"文件里没有数据行"}`)
return nil
}
c, e, err := openConn(st, cfg.VaultKey, id, dbName)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString(`{"status":2,"msg":"连接失败"}`)
return nil
}
defer c.Close()
// 表名校验:必须在 introspection 列表里,顺便拿到列清单做交集
tables, cols, err := dbx.TablesAndColumns(c, e.Engine, dbName)
if err != nil {
ctx.SetStatusCode(500)
ctx.SetBodyString(`{"status":2,"msg":"读表结构失败"}`)
return nil
}
validTable := false
tableCols := map[string]bool{}
for _, t := range tables {
if t.Name == table {
validTable = true
}
}
for _, cl := range cols {
if cl.Table == table {
tableCols[cl.Name] = true
}
}
if !validTable {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"表不存在"}`)
return nil
}
// 列名 = 文件表头 ∩ 表列
fileCols := orderedKeys(rows)
useCols := []string{}
for _, k := range fileCols {
if tableCols[k] {
useCols = append(useCols, k)
}
}
if len(useCols) == 0 {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"文件列名与表列没有交集"}`)
return nil
}
inserted, skipped, err := bulkInsert(c, e.Engine, table, useCols, rows)
if err != nil {
ctx.SetStatusCode(500)
ctx.SetBodyString(`{"status":2,"msg":"导入失败:` + err.Error() + `"}`)
return nil
}
ctx.SetContentType("application/json")
fmt.Fprintf(ctx, `{"status":0,"inserted":%d,"skipped":%d}`, inserted, skipped)
return nil
}
}
// bulkInsert 逐行插入(不开事务:PG 事务内单行失败会整批回滚,
// 逐行保证「能导多少导多少」,跳过坏行计数返回)。单次最多 1 万行。
func bulkInsert(d *sql.DB, engine, table string, cols []string, rows []map[string]any) (int64, int64, error) {
if len(rows) > 10000 {
rows = rows[:10000]
}
quote := func(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
if engine == "mysql" {
quote = func(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
}
quoted := make([]string, len(cols))
placeholders := make([]string, len(cols))
for i, c := range cols {
quoted[i] = quote(c)
if engine == "postgres" {
placeholders[i] = "$" + strconv.Itoa(i+1)
} else {
placeholders[i] = "?"
}
}
stmt := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
quote(table), strings.Join(quoted, ","), strings.Join(placeholders, ","))
ps, err := d.Prepare(stmt)
if err != nil {
return 0, 0, err
}
defer ps.Close()
var inserted, skipped int64
for _, r := range rows {
vals := make([]any, len(cols))
for i, c := range cols {
vals[i] = r[c]
}
if _, err := ps.Exec(vals...); err != nil {
skipped++
continue
}
inserted++
}
return inserted, skipped, nil
}
// parseImport 按扩展名分派:.json 对象数组 / .xlsx 首行表头 / 其余按 CSV(兼容 BOM)
func parseImport(filename string, raw []byte) ([]map[string]any, error) {
switch {
case strings.HasSuffix(filename, ".json"):
var rows []map[string]any
if err := json.Unmarshal(raw, &rows); err != nil {
return nil, fmt.Errorf("JSON 解析失败:文件应为对象数组 [{...}, ...]")
}
return rows, nil
case strings.HasSuffix(filename, ".xlsx"):
f, err := excelize.OpenReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("Excel 解析失败")
}
defer f.Close()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, fmt.Errorf("Excel 文件没有工作表")
}
recs, err := f.GetRows(sheets[0])
if err != nil {
return nil, fmt.Errorf("读取工作表失败")
}
return rowsFromTable(recs), nil
default:
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})))
r.FieldsPerRecord = -1 // 容忍长短不一的行
recs, err := r.ReadAll()
if err != nil {
return nil, fmt.Errorf("CSV 解析失败")
}
return rowsFromTable(recs), nil
}
}
// rowsFromTable 二维表(首行表头)→ 对象数组
func rowsFromTable(recs [][]string) []map[string]any {
if len(recs) < 2 {
return nil
}
head := recs[0]
out := []map[string]any{}
for _, rec := range recs[1:] {
m := map[string]any{}
for i, h := range head {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if i < len(rec) {
m[h] = rec[i]
}
}
out = append(out, m)
}
return out
}
// orderedKeys 取文件列名(按首行出现顺序)
func orderedKeys(rows []map[string]any) []string {
seen := map[string]bool{}
out := []string{}
for _, r := range rows {
for k := range r {
if !seen[k] {
seen[k] = true
out = append(out, k)
}
}
}
return out
}
+163
View File
@@ -0,0 +1,163 @@
package service
import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
const shareCodeLen = 10
const shareAlphabet = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
// DiskSvc 网盘:文件列表/删除/分享链接管理(按用户隔离,属主校验)
type DiskSvc struct {
fun.Ctx
Store *store.Store
Cfg *config.Config
}
func shareCode() (string, error) {
buf := make([]byte, shareCodeLen)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i := range buf {
buf[i] = shareAlphabet[int(buf[i])%len(shareAlphabet)]
}
return string(buf), nil
}
func fileViews(files []store.File) []dto.FileView {
out := make([]dto.FileView, 0, len(files))
for _, f := range files {
out = append(out, dto.FileView{
Id: f.Id, Name: f.Name, Size: f.Size, Mime: f.Mime, CreatedAt: f.CreatedAt,
})
}
return out
}
func shareViews(shares []store.Share) []dto.ShareView {
now := time.Now().Unix()
out := make([]dto.ShareView, 0, len(shares))
for _, s := range shares {
out = append(out, dto.ShareView{
Id: s.Id, Code: s.Code, FileName: s.FileName,
ExpiresAt: s.ExpiresAt, Expired: s.ExpiresAt > 0 && now > s.ExpiresAt,
Downloads: s.Downloads, CreatedAt: s.CreatedAt,
})
}
return out
}
func (s *DiskSvc) List() ([]dto.FileView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return nil, err
}
files, err := s.Store.ListFiles(su.User.Id)
if err != nil {
return nil, fmt.Errorf("disk: list failed: %w", err)
}
return fileViews(files), nil
}
// DeleteFile 删文件:库记录 + 磁盘文件 + 关联分享一起清
func (s *DiskSvc) DeleteFile(d dto.IdDto) (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{}, err
}
f, err := s.Store.GetFile(d.Id)
if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id {
return dto.OkView{}, fun.Error(4004, "文件不存在")
}
if err != nil {
return dto.OkView{}, err
}
if _, err := s.Store.DeleteFile(d.Id, su.User.Id); err != nil {
return dto.OkView{}, fmt.Errorf("disk: delete failed: %w", err)
}
_ = s.Store.DeleteSharesByFile(d.Id)
_ = os.Remove(filepath.Join(s.Cfg.DiskDir, f.Stored))
return dto.OkView{Message: "已删除"}, nil
}
// CreateShare ExpireMinutes=0 永久;>0 限时分享
func (s *DiskSvc) CreateShare(d dto.CreateShareDto) (dto.ShareView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.ShareView{}, err
}
f, err := s.Store.GetFile(d.FileId)
if errors.Is(err, store.ErrNotFound) || f.UserID != su.User.Id {
return dto.ShareView{}, fun.Error(4004, "文件不存在")
}
if err != nil {
return dto.ShareView{}, err
}
if d.ExpireMinutes < 0 || d.ExpireMinutes > 60*24*365 {
return dto.ShareView{}, fun.Error(4001, "有效期不合法")
}
code, err := shareCode()
if err != nil {
return dto.ShareView{}, err
}
var expires int64
if d.ExpireMinutes > 0 {
expires = time.Now().Add(time.Duration(d.ExpireMinutes) * time.Minute).Unix()
}
sh := store.Share{
Code: code, FileID: f.Id, UserID: su.User.Id,
FileName: f.Name, ExpiresAt: expires,
}
if err := s.Store.InsertShare(&sh); err != nil {
return dto.ShareView{}, fmt.Errorf("disk: create share failed: %w", err)
}
return shareViews([]store.Share{sh})[0], nil
}
func (s *DiskSvc) ListShares() ([]dto.ShareView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return nil, err
}
shares, err := s.Store.ListShares(su.User.Id)
if err != nil {
return nil, fmt.Errorf("disk: list shares failed: %w", err)
}
return shareViews(shares), nil
}
func (s *DiskSvc) DeleteShare(d dto.IdDto) (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{}, err
}
n, err := s.Store.DeleteShare(d.Id, su.User.Id)
if err != nil {
return dto.OkView{}, fmt.Errorf("disk: delete share failed: %w", err)
}
if n == 0 {
return dto.OkView{}, fun.Error(4004, "分享不存在")
}
return dto.OkView{Message: "已删除"}, nil
}
// safeStored 落盘文件名清洗:只留安全字符,防路径穿越
func safeStored(name string) string {
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "..", "_")
return name
}
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"errors"
"fmt"
"io"
"mime"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/valyala/fasthttp"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// routeUser 自定义路由的登录用户校验(区别于 routeAdmin:任何登录用户可用)
func routeUser(st *store.Store, ctx *fasthttp.RequestCtx) (store.SessionUser, error) {
token := string(ctx.QueryArgs().Peek("token"))
if token == "" {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
return store.SessionUser{}, errors.New("missing token")
}
su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter)
if err != nil || su.User.Status != "active" {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
return store.SessionUser{}, errors.New("invalid token")
}
return su, nil
}
func routeJSONErr(ctx *fasthttp.RequestCtx, code int, msg string) {
ctx.SetStatusCode(code)
ctx.SetContentType("application/json")
ctx.SetBodyString(`{"status":2,"msg":"` + msg + `"}`)
}
// DiskUploadHandler POST /disk/upload?token= multipart 字段 file
func DiskUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
su, err := routeUser(st, ctx)
if err != nil {
routeJSONErr(ctx, 401, "未登录或登录已失效")
return nil
}
fh, err := ctx.FormFile("file")
if err != nil {
routeJSONErr(ctx, 400, "缺少文件")
return nil
}
src, err := fh.Open()
if err != nil {
routeJSONErr(ctx, 400, "读取上传失败")
return nil
}
defer src.Close()
// 落盘名 = 时间戳+随机后缀(不沿用原名,天然免冲突与穿越)
stored := fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeStored(filepath.Base(fh.Filename)))
dstPath := filepath.Join(cfg.DiskDir, stored)
dst, err := os.Create(dstPath)
if err != nil {
routeJSONErr(ctx, 500, "写入失败")
return nil
}
size, err := io.Copy(dst, src)
dst.Close()
if err != nil {
_ = os.Remove(dstPath)
routeJSONErr(ctx, 500, "保存失败")
return nil
}
mimeType := fh.Header.Get("Content-Type")
f := store.File{
UserID: su.User.Id, Name: filepath.Base(fh.Filename),
Stored: stored, Size: size, Mime: mimeType,
}
if err := st.InsertFile(&f); err != nil {
_ = os.Remove(dstPath)
routeJSONErr(ctx, 500, "记录失败")
return nil
}
ctx.SetContentType("application/json")
fmt.Fprintf(ctx, `{"status":0,"id":%d,"name":%q,"size":%d}`, f.Id, f.Name, f.Size)
return nil
}
}
// serveDiskFile 共用:按原始文件名附件回传
func serveDiskFile(ctx *fasthttp.RequestCtx, dir, stored, name, mimeType string, size int64) {
path := filepath.Join(dir, stored)
f, err := os.Open(path)
if err != nil {
ctx.SetStatusCode(404)
ctx.SetBodyString("文件已不存在")
return
}
defer f.Close()
// RFC 5987:中文文件名走 filename*,再补一份 filename 兜底
disp := "attachment; filename*=UTF-8''" + url.PathEscape(name)
ctx.Response.Header.Set("Content-Disposition", disp)
if mimeType != "" {
ctx.SetContentType(mimeType)
} else if mt := mime.TypeByExtension(filepath.Ext(name)); mt != "" {
ctx.SetContentType(mt)
} else {
ctx.SetContentType("application/octet-stream")
}
ctx.Response.Header.Set("Content-Length", strconv.FormatInt(size, 10))
_, _ = io.Copy(ctx, f)
}
// DiskDownloadHandler GET /disk/download?id=&token= 属主下载
func DiskDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
su, err := routeUser(st, ctx)
if err != nil {
routeJSONErr(ctx, 401, "未登录或登录已失效")
return nil
}
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
f, err := st.GetFile(id)
if err != nil || f.UserID != su.User.Id {
ctx.SetStatusCode(404)
ctx.SetBodyString("文件不存在")
return nil
}
serveDiskFile(ctx, cfg.DiskDir, f.Stored, f.Name, f.Mime, f.Size)
return nil
}
}
// ShareDownloadHandler GET /d/* 公开分享下载:code 即 wildcard 路径,过期返回 410
func ShareDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
code := strings.Trim(rc.Wildcard, "/")
if code == "" {
ctx.SetStatusCode(404)
ctx.SetBodyString("链接无效")
return nil
}
v, err := st.GetShareFile(code)
if err != nil {
ctx.SetStatusCode(404)
ctx.SetBodyString("分享链接不存在或已删除")
return nil
}
if v.ExpiresAt > 0 && time.Now().Unix() > v.ExpiresAt {
ctx.SetStatusCode(410)
ctx.SetContentType("text/html; charset=utf-8")
ctx.SetBodyString(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;display:flex;height:100vh;align-items:center;justify-content:center;background:#f8f6f6"><div style="text-align:center"><h1 style="font-size:48px;margin:0">链接已过期</h1><p style="color:#8a857e">这个分享已经过了有效期,找文件的主人再要一个新链接吧</p></div></body>`)
return nil
}
_ = st.IncShareDownloads(code)
serveDiskFile(ctx, cfg.DiskDir, v.Stored, v.FileName, v.Mime, v.Size)
return nil
}
}
+203
View File
@@ -0,0 +1,203 @@
// license.go 授权系统管理:项目(appid/secret)+ 激活卡密(绑域名、按时长计时)。
// 对外校验走 /v1/license,仅供其他项目客户端调用。
package service
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/db"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// LicenseSvc 授权管理(仅管理员)。
type LicenseSvc struct {
fun.Ctx
Store *store.Store
}
const (
licenseUnused = 0 // 未使用
licenseActive = 1 // 已激活(有效期内)
licenseExpired = 2 // 已过期
)
// ---- 项目管理 ----
func (s *LicenseSvc) Apps() (dto.LicenseAppsView, error) {
rows, err := s.Store.Q().ListLicenseApps(context.Background())
if err != nil {
return dto.LicenseAppsView{}, err
}
items := make([]dto.LicenseAppView, 0, len(rows))
for _, r := range rows {
items = append(items, dto.LicenseAppView{
Id: r.ID, Appid: r.Appid, Name: r.Name,
Enabled: r.Enabled, CardCount: r.CardCount, CreatedAt: r.CreatedAt,
})
}
return dto.LicenseAppsView{Items: items}, nil
}
// SaveApp id=0 新建(自动生成 appid+secret);否则改名/开关/重置 secret。
func (s *LicenseSvc) SaveApp(d dto.LicenseAppSaveDto) (dto.LicenseAppView, error) {
ctx := context.Background()
if d.Id == 0 {
name := strings.TrimSpace(d.Name)
if name == "" || len(name) > 50 {
return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字")
}
appid := "app-" + randLowerHex(4)
id, err := s.Store.Q().CreateLicenseApp(ctx, db.CreateLicenseAppParams{
Appid: appid, Secret: "", Name: name, CreatedAt: time.Now().Unix(),
})
if err != nil {
return dto.LicenseAppView{}, fmt.Errorf("创建项目失败: %w", err)
}
return dto.LicenseAppView{Id: id, Appid: appid, Name: name, Enabled: 1}, nil
}
name := strings.TrimSpace(d.Name)
if name == "" || len(name) > 50 {
return dto.LicenseAppView{}, fun.Error(4001, "项目名必填且不超过 50 字")
}
enabled := int64(1)
if d.Enabled != nil {
enabled = *d.Enabled
}
if err := s.Store.Q().UpdateLicenseApp(ctx, db.UpdateLicenseAppParams{
Name: name, Enabled: enabled, ID: d.Id,
}); err != nil {
return dto.LicenseAppView{}, err
}
return dto.LicenseAppView{Id: d.Id, Name: name, Enabled: enabled}, nil
}
func (s *LicenseSvc) DeleteApp(d dto.LicenseIdDto) (dto.OkView, error) {
if err := s.Store.Q().DeleteLicenseApp(context.Background(), d.Id); err != nil {
return dto.OkView{}, err
}
return dto.OkView{Message: "已删除"}, nil
}
// ---- 卡密管理 ----
func licenseStatus(c db.LicenseCard, now int64) int64 {
if c.ActivatedAt == 0 {
return licenseUnused
}
if c.ExpiresAt > 0 && c.ExpiresAt <= now { // expires_at=0 表示永久
return licenseExpired
}
return licenseActive
}
func licenseView(c db.LicenseCard, now int64) dto.LicenseCardView {
return dto.LicenseCardView{
Id: c.ID, Card: c.Card, Hours: c.Hours, Domain: c.Domain,
Status: licenseStatus(c, now),
ActivatedAt: c.ActivatedAt, ExpiresAt: c.ExpiresAt, CreatedAt: c.CreatedAt,
}
}
func (s *LicenseSvc) List(d dto.LicenseListDto) (dto.LicensePageView, error) {
kw := sql.NullString{}
if d.Kw != nil {
kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true}
}
page := max(d.Page, 1)
size := min(max(d.PageSize, 1), 200)
ctx := context.Background()
total, err := s.Store.Q().CountLicenseCards(ctx, db.CountLicenseCardsParams{
AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw,
})
if err != nil {
return dto.LicensePageView{}, err
}
rows, err := s.Store.Q().ListLicenseCards(ctx, db.ListLicenseCardsParams{
AppID: d.AppId, Column2: kw.String, Column3: kw, Column4: kw,
Limit: size, Offset: (page - 1) * size,
})
if err != nil {
return dto.LicensePageView{}, err
}
now := time.Now().Unix()
items := make([]dto.LicenseCardView, 0, len(rows))
for _, r := range rows {
items = append(items, licenseView(r, now))
}
return dto.LicensePageView{Total: total, Items: items}, nil
}
func (s *LicenseSvc) Stats(d dto.LicenseStatsDto) (dto.LicenseStatsView, error) {
now := time.Now().Unix()
r, err := s.Store.Q().LicenseCardStats(context.Background(), db.LicenseCardStatsParams{
ExpiresAt: now, ExpiresAt_2: now, AppID: d.AppId,
})
if err != nil {
return dto.LicenseStatsView{}, err
}
return dto.LicenseStatsView{Total: r.Total, Unused: anyI64(r.Unused), Active: anyI64(r.Active), Expired: anyI64(r.Expired)}, nil
}
// Generate 批量生成卡密:大写分组格式(XXXX-XXXX-XXXX-XXXX),一次最多 500。
func (s *LicenseSvc) Generate(d dto.LicenseGenDto) (dto.LicenseGenView, error) {
if d.AppId <= 0 {
return dto.LicenseGenView{}, fun.Error(4001, "请先选择项目")
}
count := min(max(d.Count, 1), 500)
hours := min(max(d.Hours, 0), 24*3650) // 0 = 永久
ctx := context.Background()
now := time.Now().Unix()
cards := make([]string, 0, count)
for i := int64(0); i < count; i++ {
card := genLicenseCard()
if err := s.Store.Q().CreateLicenseCard(ctx, db.CreateLicenseCardParams{
AppID: d.AppId, Card: card, Hours: hours, CreatedAt: now,
}); err != nil {
return dto.LicenseGenView{}, fmt.Errorf("生成卡密失败(%d/%d): %w", i, count, err)
}
cards = append(cards, card)
}
return dto.LicenseGenView{Cards: cards}, nil
}
func (s *LicenseSvc) Delete(d dto.LicenseIdDto) (dto.OkView, error) {
if err := s.Store.Q().DeleteLicenseCard(context.Background(), d.Id); err != nil {
return dto.OkView{}, err
}
return dto.OkView{Message: "已删除"}, nil
}
// genLicenseCard XXXX-XXXX-XXXX-XXXX 大写卡密,排除易混淆字符(0/O、1/I/L)。
func genLicenseCard() string {
const chars = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
var sb strings.Builder
for i, v := range b {
if i > 0 && i%4 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(chars[int(v)%len(chars)])
}
return sb.String()
}
// randLowerHex n 字节随机小写 hex。
func randLowerHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,82 @@
// license_routes.go 卡密公开校验端点:GET/POST /v1/license?appid=xx&card=xx&domain=xx
// 首次调用激活并绑定域名(开始计时),之后仅限同域名校验;供其他项目做网络验证。
package service
import (
"context"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/db"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
func LicenseVerifyHandler(st *store.Store) fun.RouteHandler {
return func(c *fun.RouteCtx) error {
appid := strings.TrimSpace(c.Param("appid"))
card := strings.ToUpper(strings.TrimSpace(c.Param("card")))
domain := normalizeDomain(c.Param("domain"))
if appid == "" || card == "" || domain == "" {
return licenseJSON(c, 0, "缺少参数:appid / card / domain", nil)
}
ctx := context.Background()
app, err := st.Q().GetLicenseAppByAppid(ctx, appid)
if err != nil || app.Enabled != 1 {
return licenseJSON(c, 0, "项目不存在或已停用", nil)
}
lc, err := st.Q().GetLicenseCard(ctx, db.GetLicenseCardParams{AppID: app.ID, Card: card})
if err != nil {
return licenseJSON(c, 0, "卡密无效", nil)
}
now := time.Now().Unix()
if lc.ActivatedAt == 0 {
// 首激:绑域名 + 计时开始;hours=0 为永久卡(expires_at=0 永不过期)
expires := int64(0)
if lc.Hours > 0 {
expires = now + lc.Hours*3600
}
if err := st.Q().ActivateLicenseCard(ctx, db.ActivateLicenseCardParams{
Domain: domain, ActivatedAt: now, ExpiresAt: expires, ID: lc.ID,
}); err != nil {
return licenseJSON(c, 0, "激活失败", nil)
}
return licenseJSON(c, 1, "激活成功", map[string]any{
"domain": domain, "hours": lc.Hours, "expires_at": expires,
})
}
if lc.Domain != domain {
return licenseJSON(c, 0, "卡密已绑定其他域名", nil)
}
if lc.ExpiresAt > 0 && lc.ExpiresAt <= now {
return licenseJSON(c, 0, "卡密已过期", map[string]any{"expires_at": lc.ExpiresAt})
}
return licenseJSON(c, 1, "验证通过", map[string]any{
"domain": lc.Domain, "hours": lc.Hours, "expires_at": lc.ExpiresAt,
})
}
}
// normalizeDomain 取纯主机名:去协议/路径/端口/末尾点,小写。
func normalizeDomain(raw string) string {
d := strings.ToLower(strings.TrimSpace(raw))
if i := strings.Index(d, "://"); i >= 0 {
d = d[i+3:]
}
if i := strings.IndexAny(d, "/?#"); i >= 0 {
d = d[:i]
}
if i := strings.Index(d, ":"); i >= 0 {
d = d[:i]
}
return strings.TrimSuffix(d, ".")
}
func licenseJSON(c *fun.RouteCtx, code int, msg string, extra map[string]any) error {
obj := map[string]any{"code": code, "msg": msg}
for k, v := range extra {
obj[k] = v
}
return epayJSON(c, obj)
}
+122
View File
@@ -0,0 +1,122 @@
// memory.go 共享记忆库管理(仅管理员):项目分组、搜索、读写删除、MCP 接入信息。
// 对外协议端点见 memory_routes.go(POST /mcp + GET /m/<key>)。
package service
import (
"strings"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/memx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// MemorySvc 记忆库管理(仅管理员)。
type MemorySvc struct {
fun.Ctx
Store *store.Store
}
func memoryMeta(m store.Memory) dto.MemoryMetaView {
return dto.MemoryMetaView{
Key: m.Key, Project: m.Project, Tags: m.Tags,
WrittenBy: m.WrittenBy, Revision: m.Revision, UpdatedAt: m.UpdatedAt,
}
}
func (s *MemorySvc) Projects() (dto.MemoryProjectsView, error) {
rows, err := s.Store.MemoryProjects()
if err != nil {
return dto.MemoryProjectsView{}, err
}
items := make([]dto.MemoryProjectView, 0, len(rows))
for _, r := range rows {
items = append(items, dto.MemoryProjectView{Name: r.Name, Count: r.Count})
}
return dto.MemoryProjectsView{Items: items}, nil
}
// List query 非空走全文 AND 搜索;否则按 project/tag 过滤列元数据
func (s *MemorySvc) List(d dto.MemoryListDto) (dto.MemoryListView, error) {
var ms []store.Memory
var err error
if q := strings.TrimSpace(d.Query); q != "" {
ms, err = s.Store.SearchMemories(q, strings.TrimSpace(d.Project), d.Limit)
} else {
ms, err = s.Store.ListMemories(strings.TrimSpace(d.Project), strings.TrimSpace(d.Tag), d.Limit)
}
if err != nil {
return dto.MemoryListView{}, err
}
items := make([]dto.MemoryMetaView, 0, len(ms))
for _, m := range ms {
items = append(items, memoryMeta(m))
}
return dto.MemoryListView{Items: items}, nil
}
func (s *MemorySvc) Get(d dto.MemoryKeyDto) (dto.MemoryView, error) {
m, err := s.Store.GetMemory(strings.TrimSpace(d.Key))
if err != nil {
return dto.MemoryView{}, err
}
if m == nil {
return dto.MemoryView{}, fun.Error(4004, "记忆不存在: "+d.Key)
}
readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key)
return dto.MemoryView{
Key: m.Key, Url: readURL, Content: m.Content, Project: m.Project, Tags: m.Tags,
WrittenBy: m.WrittenBy, Revision: m.Revision,
CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt,
}, nil
}
func (s *MemorySvc) Save(d dto.MemorySaveDto) (dto.MemorySaveView, error) {
if err := memx.LintContent(d.Content); err != nil {
return dto.MemorySaveView{}, fun.Error(4002, err.Error())
}
project := strings.TrimSpace(d.Project)
key := strings.TrimSpace(d.Key)
if key == "" {
return dto.MemorySaveView{}, fun.Error(4001, "key 必填,形如 project/topic")
}
m := &store.Memory{
Key: key, Content: d.Content, Project: project,
Tags: d.Tags, WrittenBy: "admin",
}
if err := s.Store.SaveMemory(m); err != nil {
return dto.MemorySaveView{}, err
}
readURL, _ := s.Store.SignMemoryReadURL(requestBase(s.Ctx.RequestCtx), m.Key)
return dto.MemorySaveView{
Key: m.Key, Revision: m.Revision, Url: readURL,
}, nil
}
func (s *MemorySvc) Delete(d dto.MemoryKeyDto) (dto.OkView, error) {
if _, err := s.Store.DeleteMemory(strings.TrimSpace(d.Key)); err != nil {
return dto.OkView{}, err
}
return dto.OkView{Message: "已删除"}, nil
}
// McpInfo 返回对外 MCP 端点与 Bearer 令牌(缺省懒生成一个)
func (s *MemorySvc) McpInfo() (dto.MemoryMcpView, error) {
tok, err := s.Store.McpToken()
if err != nil {
return dto.MemoryMcpView{}, err
}
return dto.MemoryMcpView{
Url: requestBase(s.Ctx.RequestCtx) + "/mcp",
Token: tok,
}, nil
}
func (s *MemorySvc) ResetToken() (dto.MemoryMcpView, error) {
if _, err := s.Store.ResetMcpToken(); err != nil {
return dto.MemoryMcpView{}, err
}
return s.McpInfo()
}
+124
View File
@@ -0,0 +1,124 @@
// memory_routes.go 记忆库对外协议端点:
// POST /mcp Streamable HTTP MCP(Bearer 令牌鉴权,令牌见 MemorySvc.McpInfo)
// GET /m 阅读链接索引(Bearer 鉴权——key 可枚举,不能裸奔)
// GET /m/* 记忆阅读链接(?sig= 签名鉴权,HMAC(key, 令牌) 签发,令牌重置即吊销)
package service
import (
"context"
"crypto/subtle"
"net/http"
"github.com/valyala/fasthttp"
"github.com/cyi-cc/fun"
"github.com/mark3labs/mcp-go/server"
"github.com/valyala/fasthttp/fasthttpadaptor"
"github.com/cyi-cc/cyi-box/backend/internal/memx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// requestBase 按当前请求 Host + X-Forwarded-Proto 推导站点基址,
// 反代换域名后生成的 /m/ 链接与 MCP 接入地址始终可点。
func requestBase(ctx *fasthttp.RequestCtx) string {
proto := string(ctx.Request.Header.Peek("X-Forwarded-Proto"))
if proto == "" {
proto = "http"
}
return proto + "://" + string(ctx.Host())
}
// McpHandler POST /mcp:Authorization Bearer 校验后交给 Streamable HTTP 服务。
// 令牌存 settings(mcp.token),首次访问懒生成。
func McpHandler(st *store.Store) fun.RouteHandler {
mcpSrv := server.NewStreamableHTTPServer(
memx.NewMCPServer(st),
server.WithStateLess(true),
server.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
proto = "http"
if r.TLS != nil {
proto = "https"
}
}
return memx.WithBase(ctx, proto+"://"+r.Host)
}),
)
h := fasthttpadaptor.NewFastHTTPHandler(mcpSrv)
return func(c *fun.RouteCtx) error {
tok, err := st.McpToken()
if err != nil {
c.RequestCtx.SetStatusCode(500)
c.RequestCtx.WriteString(err.Error())
return nil
}
auth := string(c.RequestCtx.Request.Header.Peek("Authorization"))
if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 {
c.RequestCtx.SetStatusCode(401)
c.RequestCtx.WriteString("unauthorized")
return nil
}
h(c.RequestCtx)
return nil
}
}
// MemoryReadHandler GET /m/*:输出 markdown 原文;裸 /m/ 列出全部阅读链接。
func MemoryReadHandler(st *store.Store) fun.RouteHandler {
return func(c *fun.RouteCtx) error {
key := c.Wildcard
base := requestBase(c.RequestCtx)
if key == "" {
// 索引收进 Bearer 鉴权:key 形如 project/topic 可枚举,裸索引等于全库暴露
tok, err := st.McpToken()
if err != nil {
c.RequestCtx.SetStatusCode(500)
c.RequestCtx.WriteString(err.Error())
return nil
}
auth := string(c.RequestCtx.Request.Header.Peek("Authorization"))
if subtle.ConstantTimeCompare([]byte(auth), []byte("Bearer "+tok)) != 1 {
c.RequestCtx.SetStatusCode(401)
c.RequestCtx.WriteString("unauthorized")
return nil
}
ms, err := st.ListMemories("", "", 200)
if err != nil {
c.RequestCtx.SetStatusCode(500)
c.RequestCtx.WriteString(err.Error())
return nil
}
c.RequestCtx.SetContentType("text/plain; charset=utf-8")
for _, m := range ms {
readURL, _ := st.SignMemoryReadURL(base, m.Key)
line := readURL
if m.Project != "" {
line += "\t[" + m.Project + "]"
}
c.RequestCtx.WriteString(line + "\n")
}
return nil
}
if !st.CheckMemoryReadSig(key, string(c.RequestCtx.QueryArgs().Peek("sig"))) {
c.RequestCtx.SetStatusCode(403)
c.RequestCtx.WriteString("bad or missing sig")
return nil
}
m, err := st.GetMemory(key)
if err != nil {
c.RequestCtx.SetStatusCode(500)
c.RequestCtx.WriteString(err.Error())
return nil
}
if m == nil {
c.RequestCtx.SetStatusCode(404)
c.RequestCtx.WriteString("not found")
return nil
}
c.RequestCtx.SetContentType("text/markdown; charset=utf-8")
c.RequestCtx.WriteString(m.Content)
return nil
}
}
+867
View File
@@ -0,0 +1,867 @@
// pay.go 支付核心:上游 catfk 账号托管、分类/商品自检、卡密库存保障(全程带锁)、
// 订单 RPC、支付轮询与商户异步通知。易支付协议端点在 pay_routes.go。
package service
import (
"context"
"crypto/md5"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/db"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/payx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// ---- 常量与 settings 键 ----
const (
payStatusPending = int64(0)
payStatusPaid = int64(1)
payStatusClosed = int64(3)
payUnitFen = int64(1) // 上游商品单价 0.01 元 = 1 分
payTargetFen = int64(100000) // 库存目标 1000 元 = 100000 分 = 10w 张卡
payCatName = "CDK"
payGoodsName = "CDK0.01元"
payMaxFen = int64(200000) // 单笔上限 2000 元
)
const (
keyPayPid = "pay_pid"
keyPayKey = "pay_key"
keyUpUsername = "up_username"
keyUpPassword = "up_password" // AES 加密存储
keyUpNickname = "up_nickname"
keyUpShop = "up_shop"
keyUpGoodsID = "up_goods_id"
keyUpGoodsKey = "up_goods_key"
keyUpGoodsName = "up_goods_name"
keyUpLastErr = "up_last_error"
)
// ---- 易支付签名与金额工具 ----
// epaySign 标准 MD5 签名:参数名 ASCII 升序拼 k=v&…(忽略 sign/sign_type 与空值),末尾直接拼接商户密钥。
func epaySign(params map[string]string, key string) string {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || k == "sign_type" || v == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if b.Len() > 0 {
b.WriteByte('&')
}
b.WriteString(k)
b.WriteByte('=')
b.WriteString(params[k])
}
b.WriteString(key)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
func epayVerify(params map[string]string, key, sign string) bool {
if params["sign_type"] != "MD5" {
return false
}
return sign != "" && epaySign(params, key) == sign
}
// fenToYuan 分转元字符串,如 199 -> "1.99"。
func fenToYuan(fen int64) string {
sign := ""
if fen < 0 {
sign, fen = "-", -fen
}
return fmt.Sprintf("%s%d.%02d", sign, fen/100, fen%100)
}
// yuanToFen 元字符串转分(最多两位小数)。
func yuanToFen(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("金额不能为空")
}
if strings.HasPrefix(s, "-") {
return 0, fmt.Errorf("金额不能为负")
}
parts := strings.SplitN(s, ".", 2)
whole, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
if err != nil {
return 0, fmt.Errorf("金额格式错误")
}
fen := whole * 100
if len(parts) == 2 {
frac := parts[1]
if len(frac) > 2 {
frac = frac[:2]
}
frac += strings.Repeat("0", 2-len(frac))
f, err := strconv.ParseInt(frac, 10, 64)
if err != nil {
return 0, fmt.Errorf("金额格式错误")
}
fen += f
}
return fen, nil
}
// genTradeNo 平台订单号:P + 时间戳 + 16 位随机 hex(不可枚举)。
func genTradeNo() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "P" + strconv.FormatInt(time.Now().Unix(), 10) + hex.EncodeToString(b)
}
// ---- 商户凭证 ----
func payMerchant(st *store.Store) (pid, key string, err error) {
if pid, err = st.GetSetting(keyPayPid); err != nil {
return "", "", err
}
if key, err = st.GetSetting(keyPayKey); err != nil {
return "", "", err
}
return pid, key, nil
}
func strSetting(st *store.Store, key string) string {
v, _ := st.GetSetting(key)
return v
}
func intSetting(st *store.Store, key string) int64 {
v, _ := strconv.ParseInt(strSetting(st, key), 10, 64)
return v
}
func anyI64(v any) int64 {
switch t := v.(type) {
case int64:
return t
case float64:
return int64(t)
case []byte:
n, _ := strconv.ParseInt(string(t), 10, 64)
return n
case string:
n, _ := strconv.ParseInt(t, 10, 64)
return n
}
return 0
}
// ---- PaySvc 管理端 RPC ----
type PaySvc struct {
fun.Ctx
Store *store.Store
Up *payx.Client
Cfg *config.Config
}
// List 订单分页。
func (s *PaySvc) List(d dto.PayOrderListDto) (dto.PayOrderPageView, error) {
page, size := d.Page, d.PageSize
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
status := int64(-1)
if d.Status != nil {
status = *d.Status
}
kw := sql.NullString{}
if d.Kw != nil {
kw = sql.NullString{String: strings.TrimSpace(*d.Kw), Valid: true}
}
ctx := context.Background()
arg := db.ListPayOrdersParams{
Column1: status, Status: status,
Column3: kw.String, Column4: kw, Column5: kw,
Limit: size, Offset: (page - 1) * size,
}
rows, err := s.Store.Q().ListPayOrders(ctx, arg)
if err != nil {
return dto.PayOrderPageView{}, fun.Error(5000, "查询订单失败")
}
total, err := s.Store.Q().CountPayOrders(ctx, db.CountPayOrdersParams{
Column1: arg.Column1, Status: arg.Status,
Column3: arg.Column3, Column4: arg.Column4, Column5: arg.Column5,
})
if err != nil {
return dto.PayOrderPageView{}, fun.Error(5000, "统计订单失败")
}
items := make([]dto.PayOrderView, 0, len(rows))
for _, r := range rows {
items = append(items, toPayOrderView(r))
}
return dto.PayOrderPageView{Total: total, Items: items}, nil
}
// Stats 今日与累计统计。
func (s *PaySvc) Stats() (dto.PayStatsView, error) {
now := time.Now()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
ctx := context.Background()
today, err := s.Store.Q().PayOrderStats(ctx, dayStart)
if err != nil {
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
}
total, err := s.Store.Q().PayOrderStats(ctx, 0)
if err != nil {
return dto.PayStatsView{}, fun.Error(5000, "统计失败")
}
return dto.PayStatsView{
TodayCount: today.Cnt, TodayMoney: anyI64(today.Money), TodayPaidCount: anyI64(today.Paid),
TotalCount: total.Cnt, TotalMoney: anyI64(total.Money), PendingCount: anyI64(total.Pending),
TotalPaidCount: anyI64(total.Paid),
}, nil
}
// UpstreamStatus 上游账号与商品状态(管理页展示)。
func (s *PaySvc) UpstreamStatus() (dto.PayUpstreamStatusView, error) {
v := dto.PayUpstreamStatusView{
Username: strSetting(s.Store, keyUpUsername),
Nickname: strSetting(s.Store, keyUpNickname),
Shop: strSetting(s.Store, keyUpShop),
GoodsName: strSetting(s.Store, keyUpGoodsName),
LastError: strSetting(s.Store, keyUpLastErr),
TokenAge: s.Up.TokenAge(),
GoodsId: intSetting(s.Store, keyUpGoodsID),
GoodsKey: strSetting(s.Store, keyUpGoodsKey),
UnitPrice: payUnitFen,
TargetMoney: payTargetFen,
}
if payEnsuring.Load() {
v.Ensuring = 1
}
if v.Username != "" {
v.Configured = 1
}
if v.GoodsId > 0 && v.Configured == 1 {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, _, stock, err := s.Up.GoodsInfo(ctx, v.GoodsId); err == nil {
v.StockCount = stock
}
if avail, frozen, err := s.Up.WalletInfo(ctx); err == nil {
v.WalletOk, v.WalletAvail, v.WalletFrozen = 1, avail, frozen
}
}
return v, nil
}
// SaveUpstream 保存上游账号并登录验证;成功后异步触发分类/商品自检与库存补齐。
func (s *PaySvc) SaveUpstream(d dto.PayUpstreamDto) (dto.PayUpstreamStatusView, error) {
username := strings.TrimSpace(d.Username)
if username == "" {
return dto.PayUpstreamStatusView{}, fun.Error(4001, "上游账号不能为空")
}
password := d.Password
if password == "" {
u, p, err := s.decryptUpPassword()
if err != nil || u != username || p == "" {
return dto.PayUpstreamStatusView{}, fun.Error(4001, "请输入上游密码")
}
password = p
}
if err := s.Up.SetCredentials(username, password); err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(4002, "上游登录失败:"+err.Error())
}
nickname, shop, err := s.Up.Userinfo(context.Background())
if err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "获取商户信息失败:"+err.Error())
}
enc, err := vault.Encrypt(s.Cfg.VaultKey, password)
if err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "凭据加密失败")
}
old := strSetting(s.Store, keyUpUsername)
sets := map[string]string{
keyUpUsername: username, keyUpPassword: enc,
keyUpNickname: nickname, keyUpShop: shop,
}
if old != "" && old != username {
sets[keyUpGoodsID], sets[keyUpGoodsKey], sets[keyUpGoodsName] = "", "", ""
}
for k, v := range sets {
if err := s.Store.SetSetting(k, v); err != nil {
return dto.PayUpstreamStatusView{}, fun.Error(5000, "保存配置失败")
}
}
ensureGoodsAsync(s.Store, s.Up)
return s.UpstreamStatus()
}
// TestPay 创建一笔真实订单走完整上游流程(收银台二维码 + 轮询结算)。
func (s *PaySvc) TestPay(d dto.PayTestPayDto) (dto.PayTestPayView, error) {
money, err := yuanToFen(d.Money)
if err != nil || money <= 0 || money > payMaxFen {
return dto.PayTestPayView{}, fun.Error(4001, "金额非法(0-2000 元)")
}
order, err := createPayOrder(s.Store, s.Up, createPayOrderIn{
Channel: "alipay", Subject: "测试支付", Money: money,
})
if err != nil {
return dto.PayTestPayView{}, fun.Error(5000, err.Error())
}
return dto.PayTestPayView{
TradeNo: order.TradeNo, Payurl: order.Payurl, Qrcode: order.Qrcode,
Money: order.Money, Cashier: "/cashier/" + order.TradeNo,
}, nil
}
// CashierInfo 收银台公开信息(trade_no 不可枚举即凭证)。
func (s *PaySvc) CashierInfo(d dto.PayCashierDto) (dto.PayCashierView, error) {
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
if err != nil {
return dto.PayCashierView{}, fun.Error(4004, "订单不存在")
}
lazyCloseExpired(s.Store, &o)
return dto.PayCashierView{
TradeNo: o.TradeNo, OutTradeNo: o.OutTradeNo, Subject: o.Subject,
Money: o.Money, Channel: o.Channel, Status: o.Status,
Payurl: o.Payurl, Qrcode: o.Qrcode, Cards: o.Cards,
CreatedAt: o.CreatedAt, ExpiredAt: o.ExpiredAt,
}, nil
}
// Watch 收银台状态流:服务端推送订单状态变化,终态(已支付/已关闭)自动关闭。
func (s *PaySvc) Watch(d dto.PayCashierDto) (*fun.Stream[dto.PayCashierStatusView], error) {
tradeNo := strings.TrimSpace(d.TradeNo)
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
if err != nil {
return nil, fun.Error(4004, "订单不存在")
}
st := &fun.Stream[dto.PayCashierStatusView]{}
go s.watchLoop(st, tradeNo, o)
return st, nil
}
func (s *PaySvc) watchLoop(st *fun.Stream[dto.PayCashierStatusView], tradeNo string, first db.PayOrder) {
defer st.Close()
last := first.Status
push := func(status int64, o db.PayOrder) bool {
msg := dto.PayCashierStatusView{Status: status}
if status == payStatusPaid && o.ReturnUrl != "" {
if pid, key, err := payMerchant(s.Store); err == nil {
msg.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
}
}
return st.Send(msg) == nil
}
if !push(last, first) || last != payStatusPending {
return
}
deadline := time.Now().Add(40 * time.Minute)
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for time.Now().Before(deadline) {
<-t.C
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
if err != nil {
return
}
lazyCloseExpired(s.Store, &o)
if o.Status != last {
if !push(o.Status, o) {
return
}
last = o.Status
if o.Status != payStatusPending {
return
}
}
}
}
// CashierStatus 收银台轮询:已支付且配置了 return_url 时返回签名回跳地址。
func (s *PaySvc) CashierStatus(d dto.PayCashierDto) (dto.PayCashierStatusView, error) {
o, err := s.Store.Q().GetPayOrderByTradeNo(context.Background(), strings.TrimSpace(d.TradeNo))
if err != nil {
return dto.PayCashierStatusView{}, fun.Error(4004, "订单不存在")
}
res := dto.PayCashierStatusView{Status: o.Status}
if o.Status == payStatusPaid && o.ReturnUrl != "" {
pid, key, err := payMerchant(s.Store)
if err == nil {
res.ReturnUrl = buildSignedURL(o.ReturnUrl, notifyParams(o, pid), key)
}
}
return res, nil
}
func toPayOrderView(r db.PayOrder) dto.PayOrderView {
return dto.PayOrderView{
Id: r.ID, TradeNo: r.TradeNo, OutTradeNo: r.OutTradeNo, Channel: r.Channel,
Subject: r.Subject, Money: r.Money, Status: r.Status, Notified: r.Notified,
Payurl: r.Payurl, PaidAt: r.PaidAt, ExpiredAt: r.ExpiredAt, CreatedAt: r.CreatedAt,
}
}
func lazyCloseExpired(st *store.Store, o *db.PayOrder) {
if o.Status == payStatusPending && o.ExpiredAt > 0 && o.ExpiredAt < time.Now().Unix() {
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: o.PaidAt, Cards: o.Cards, ID: o.ID,
})
o.Status = payStatusClosed
}
}
func (s *PaySvc) decryptUpPassword() (string, string, error) {
u := strSetting(s.Store, keyUpUsername)
enc := strSetting(s.Store, keyUpPassword)
if u == "" || enc == "" {
return "", "", fmt.Errorf("未保存上游凭据")
}
p, err := vault.Decrypt(s.Cfg.VaultKey, enc)
return u, p, err
}
// ---- 商品自检 + 库存保障 ----
var payEnsuring atomic.Bool
// ensureGoodsAsync 后台幂等确保分类「CDK」与商品「CDK0.01元」存在并补齐库存。
func ensureGoodsAsync(st *store.Store, up *payx.Client) {
if !payEnsuring.CompareAndSwap(false, true) {
return
}
go func() {
defer payEnsuring.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
if err := ensureGoods(ctx, st, up); err != nil {
_ = st.SetSetting(keyUpLastErr, err.Error())
log.Printf("[pay] 上游商品自检失败: %v", err)
return
}
_ = st.SetSetting(keyUpLastErr, "")
}()
}
// ensureGoods 幂等自检:分类 CDK → 商品 CDK0.01元(¥0.01)→ 库存补到 1000 元(10w 张,1w/批)。
func ensureGoods(ctx context.Context, st *store.Store, up *payx.Client) error {
goodsID := intSetting(st, keyUpGoodsID)
goodsKey := strSetting(st, keyUpGoodsKey)
target := payTargetFen / payUnitFen // 10w 张
if goodsID > 0 && goodsKey != "" {
return ensureStockTo(ctx, up, goodsID, target)
}
list, total, err := up.GoodsList(ctx, "", 1)
if err != nil {
return err
}
for page := int64(1); ; page++ {
for _, it := range list {
if it.Name == payGoodsName && it.Price == payUnitFen && it.Status == 1 {
goodsID, goodsKey = it.ID, it.GoodsKey
}
}
if goodsID != 0 || page*20 >= total {
break
}
list, _, err = up.GoodsList(ctx, "", page+1)
if err != nil {
return err
}
}
if goodsID == 0 {
var catID int64
cats, err := up.CategoryListAll(ctx)
if err != nil {
return err
}
for _, cat := range cats {
if cat.Name == payCatName {
catID = cat.ID
break
}
}
if catID == 0 {
if err := up.CategoryAdd(ctx, payCatName); err != nil {
return err
}
cats, err = up.CategoryListAll(ctx)
if err != nil {
return err
}
for _, cat := range cats {
if cat.Name == payCatName {
catID = cat.ID
break
}
}
}
if catID == 0 {
return fmt.Errorf("上游分类「%s」创建失败", payCatName)
}
if goodsID, goodsKey, err = up.GoodsAdd(ctx, payGoodsName, catID, 0.01); err != nil {
return err
}
if goodsID <= 0 || goodsKey == "" {
return fmt.Errorf("上游商品创建未返回有效标识")
}
log.Printf("[pay] 已创建上游商品「%s」id=%d", payGoodsName, goodsID)
}
for k, v := range map[string]string{
keyUpGoodsID: strconv.FormatInt(goodsID, 10),
keyUpGoodsKey: goodsKey,
keyUpGoodsName: payGoodsName,
} {
if err := st.SetSetting(k, v); err != nil {
return err
}
}
return ensureStockTo(ctx, up, goodsID, target)
}
// ensureStockTo 全程带锁把库存补到 target 张。
func ensureStockTo(ctx context.Context, up *payx.Client, goodsID, target int64) error {
unlock := up.StockLock()
defer unlock()
_, err := up.EnsureStock(ctx, goodsID, target)
return err
}
// ---- 下单 ----
type createPayOrderIn struct {
OutTradeNo string
Channel string
Subject string
Money int64 // 分
NotifyURL string
ReturnURL string
ClientIP string
}
// createPayOrder 本地建单 → 库存保障(带锁)→ 上游下单 → 写回 payurl/二维码。
// 幂等:同 out_trade_no 复用未支付订单。
func createPayOrder(st *store.Store, up *payx.Client, in createPayOrderIn) (db.PayOrder, error) {
now := time.Now().Unix()
tradeNo := genTradeNo()
outNo := in.OutTradeNo
if outNo == "" {
outNo = tradeNo // 内部测试单:商户单号=平台单号
}
if exist, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo); err == nil {
switch {
case exist.Money != in.Money:
return db.PayOrder{}, fmt.Errorf("商户订单号重复且金额不一致")
case exist.Status == payStatusPaid:
return db.PayOrder{}, fmt.Errorf("该订单已支付")
case exist.Status != payStatusPending || (exist.ExpiredAt > 0 && exist.ExpiredAt < now):
return db.PayOrder{}, fmt.Errorf("订单已关闭,请更换订单号")
default:
return exist, nil
}
}
goodsID := intSetting(st, keyUpGoodsID)
goodsKey := strSetting(st, keyUpGoodsKey)
if goodsID <= 0 || goodsKey == "" {
return db.PayOrder{}, fmt.Errorf("上游商品未就绪:请先在支付配置页保存上游账号")
}
if in.Money%payUnitFen != 0 || in.Money/payUnitFen <= 0 {
return db.PayOrder{}, fmt.Errorf("金额必须是 ¥0.01 的整数倍")
}
quantity := in.Money / payUnitFen
id, err := st.Q().CreatePayOrder(context.Background(), db.CreatePayOrderParams{
TradeNo: tradeNo, OutTradeNo: outNo, Channel: in.Channel,
Subject: in.Subject, Money: in.Money, Fee: 0,
NotifyUrl: in.NotifyURL, ReturnUrl: in.ReturnURL, ClientIp: in.ClientIP,
ExpiredAt: now + 30*60, CreatedAt: now,
})
if err != nil {
return db.PayOrder{}, fmt.Errorf("创建订单失败")
}
o, err := st.Q().GetPayOrder(context.Background(), id)
if err != nil {
return db.PayOrder{}, fmt.Errorf("创建订单失败")
}
if err := placeUpstream(st, up, &o, goodsID, goodsKey, quantity); err != nil {
if o.UpstreamTradeNo == "" {
_ = st.Q().SetPayOrderStatus(context.Background(), db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
})
}
return db.PayOrder{}, err
}
return o, nil
}
// placeUpstream 库存保障(全程带锁)→ 上游下单 → 回写。
func placeUpstream(st *store.Store, up *payx.Client, o *db.PayOrder, goodsID int64, goodsKey string, quantity int64) error {
ctx := context.Background()
shop := strSetting(st, keyUpShop)
if shop == "" {
return fmt.Errorf("上游店铺未配置")
}
channelID, err := up.AlipayChannelID(ctx, shop)
if err != nil {
return fmt.Errorf("上游支付通道不可用:%v", err)
}
contact, queryPwd := payx.RandContact(), payx.RandQueryPwd()
unlock := up.StockLock()
res, err := placeUnderLock(ctx, up, goodsID, goodsKey, quantity, channelID, contact, queryPwd)
unlock()
if err != nil {
return err
}
o.Payurl = res.PayURL
o.UpstreamTradeNo = res.TradeNo
if res.TotalAmount != o.Money {
log.Printf("[pay] 订单 %s 上游金额 %s 与本地 %s 不一致", o.TradeNo, fenToYuan(res.TotalAmount), fenToYuan(o.Money))
}
if err := st.Q().SetPayOrderUpstream(ctx, db.SetPayOrderUpstreamParams{
UpstreamTradeNo: res.TradeNo, GoodsKey: goodsKey, Quantity: quantity,
UnitPrice: payUnitFen, Payurl: res.PayURL, BuyerContact: contact,
QueryPwd: queryPwd, ID: o.ID,
}); err != nil {
return fmt.Errorf("回写上游订单失败")
}
if qr, qrErr := up.FetchQRCode(ctx, res.TradeNo); qrErr == nil && qr != "" {
_ = st.Q().SetPayOrderQrcode(ctx, db.SetPayOrderQrcodeParams{Qrcode: qr, ID: o.ID})
o.Qrcode = qr
} else if qrErr != nil {
log.Printf("[pay] 订单 %s 取二维码失败: %v(payurl 仍可用)", o.TradeNo, qrErr)
}
return nil
}
// placeUnderLock 持锁执行:库存不足先补齐(补到 max(10w, 当前+需求))再下单。
func placeUnderLock(ctx context.Context, up *payx.Client, goodsID int64, goodsKey string, quantity, channelID int64, contact, queryPwd string) (payx.PayOrderResult, error) {
target := payTargetFen / payUnitFen
_, _, stock, err := up.GoodsInfo(ctx, goodsID)
if err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存查询失败:%v", err)
}
if stock < quantity {
goal := target
if stock+quantity > goal {
goal = stock + quantity
}
log.Printf("[pay] 库存 %d 不足订单需求 %d,补到 %d", stock, quantity, goal)
if _, err := up.EnsureStock(ctx, goodsID, goal); err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", err)
}
}
res, err := up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
if err != nil && strings.Contains(err.Error(), "库存") {
if _, serr := up.EnsureStock(ctx, goodsID, target+quantity); serr != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游库存补充失败:%v", serr)
}
res, err = up.CreateOrder(ctx, goodsKey, quantity, channelID, contact, queryPwd)
}
if err != nil {
return payx.PayOrderResult{}, fmt.Errorf("上游下单失败:%v", err)
}
return res, nil
}
// ---- 支付轮询 ----
// PayPoller 待支付订单轮询器(fun.Wired 单例,5s 一扫)。
type PayPoller struct {
Store *store.Store `fun:"auto"`
Up *payx.Client `fun:"auto"`
Notify *PayNotifier `fun:"auto"`
}
func (p *PayPoller) New() {
go p.loop()
}
func (p *PayPoller) loop() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range t.C {
p.sweep()
}
}
func (p *PayPoller) sweep() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
rows, err := p.Store.Q().ListPendingPayOrders(ctx)
if err != nil {
return
}
now := time.Now().Unix()
for _, o := range rows {
if o.ExpiredAt > 0 && o.ExpiredAt < now {
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
Status: payStatusClosed, PaidAt: 0, Cards: "", ID: o.ID,
})
continue
}
paid, err := p.Up.OrderPaid(ctx, o.UpstreamTradeNo)
if err != nil || !paid {
continue
}
cards, _ := p.Up.OrderCards(ctx, o.UpstreamTradeNo)
_ = p.Store.Q().SetPayOrderStatus(ctx, db.SetPayOrderStatusParams{
Status: payStatusPaid, PaidAt: now, Cards: strings.Join(cards, "\n"), ID: o.ID,
})
log.Printf("[pay] 订单 %s 已支付", o.TradeNo)
p.Notify.OnPaid(o.TradeNo)
// 库存被消耗:异步补回目标水位
if g := intSetting(p.Store, keyUpGoodsID); g > 0 {
ensureStockAsync(p.Store, p.Up, g, payTargetFen/payUnitFen)
}
}
}
var stockFilling atomic.Bool
func ensureStockAsync(st *store.Store, up *payx.Client, goodsID, target int64) {
if !stockFilling.CompareAndSwap(false, true) {
return
}
go func() {
defer stockFilling.Store(false)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
if err := ensureStockTo(ctx, up, goodsID, target); err != nil {
log.Printf("[pay] 库存回充失败: %v", err)
}
}()
}
// ---- 商户异步通知 ----
// PayNotifier 商户异步通知器(fun.Wired 单例)。
type PayNotifier struct {
Store *store.Store `fun:"auto"`
}
var notifyBackoff = []time.Duration{0, 15 * time.Second, time.Minute, 5 * time.Minute, 15 * time.Minute}
// notifyParams 通知/回跳参数(未签名)。
func notifyParams(o db.PayOrder, pid string) map[string]string {
return map[string]string{
"pid": pid,
"trade_no": o.TradeNo,
"out_trade_no": o.OutTradeNo,
"type": o.Channel,
"name": o.Subject,
"money": fenToYuan(o.Money),
"trade_status": "TRADE_SUCCESS",
}
}
// buildSignedURL 签名参数拼接到回调地址。
func buildSignedURL(base string, params map[string]string, key string) string {
u, err := url.Parse(base)
if err != nil {
return base
}
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
q.Set("sign_type", "MD5")
q.Set("sign", epaySign(params, key))
u.RawQuery = q.Encode()
return u.String()
}
// OnPaid 异步投递商户 notify_url(退避:立即/15s/1m/5m/15m)。
func (n *PayNotifier) OnPaid(tradeNo string) {
go n.deliver(tradeNo)
}
func (n *PayNotifier) deliver(tradeNo string) {
for i, delay := range notifyBackoff {
if delay > 0 {
time.Sleep(delay)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
o, err := n.Store.Q().GetPayOrderByTradeNo(ctx, tradeNo)
cancel()
if err != nil || o.Status != payStatusPaid || o.Notified == 1 || o.NotifyUrl == "" {
return
}
if n.attempt(o) {
_ = n.Store.Q().SetPayNotified(context.Background(), o.ID)
return
}
_ = n.Store.Q().IncPayNotifyAttempts(context.Background(), o.ID)
log.Printf("[pay] %s 第 %d 次通知未确认", tradeNo, i+1)
}
}
func (n *PayNotifier) attempt(o db.PayOrder) bool {
pid, key, err := payMerchant(n.Store)
if err != nil {
return false
}
params := notifyParams(o, pid)
params["sign"] = epaySign(params, key)
params["sign_type"] = "MD5"
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
cli := &http.Client{Timeout: 10 * time.Second}
if resp, err := cli.Post(o.NotifyUrl, "application/x-www-form-urlencoded",
strings.NewReader(form.Encode())); err == nil && notifyRespOK(resp) {
return true
}
sep := "?"
if strings.Contains(o.NotifyUrl, "?") {
sep = "&"
}
if resp, err := cli.Get(o.NotifyUrl + sep + form.Encode()); err == nil {
return notifyRespOK(resp)
}
return false
}
func notifyRespOK(resp *http.Response) bool {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(body)), "success")
}
// BootPay 启动时装配上游凭据(若有)并触发商品自检。
func BootPay(st *store.Store, up *payx.Client, cfg *config.Config) {
username := strSetting(st, keyUpUsername)
enc := strSetting(st, keyUpPassword)
if username == "" || enc == "" {
return
}
pass, err := vault.Decrypt(cfg.VaultKey, enc)
if err != nil || pass == "" {
log.Printf("[pay] 上游凭据解密失败: %v", err)
return
}
up.SetCredentialsLazy(username, pass)
ensureGoodsAsync(st, up)
}
+189
View File
@@ -0,0 +1,189 @@
// pay_routes.go 易支付兼容协议端点:/submit.php(跳收银台)、/mapi.php(JSON下单)、/api.php(查单)。
package service
import (
"context"
"encoding/json"
"strings"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/payx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// paySubmitHandler GET/POST /submit.php:验签建单后 302 到收银台。
func PaySubmitHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
return func(c *fun.RouteCtx) error {
order, err := epayCreateOrder(c, st, up)
if err != nil {
return epayFail(c, err)
}
// 当码支付:优先跳二维码支付链接(qr.alipay.com),退化到本地收银台
target := order.Qrcode
if target == "" {
target = "/cashier/" + order.TradeNo
}
c.RequestCtx.Redirect(target, 302)
return nil
}
}
// payMapiHandler GET/POST /mapi.php:验签建单,JSON 返回 payurl/qrcode。
func PayMapiHandler(st *store.Store, up *payx.Client) fun.RouteHandler {
return func(c *fun.RouteCtx) error {
order, err := epayCreateOrder(c, st, up)
if err != nil {
return epayFail(c, err)
}
payurl := order.Qrcode
if payurl == "" {
payurl = order.Payurl
}
return epayJSON(c, map[string]any{
"code": 1, "msg": "success",
"trade_no": order.TradeNo,
"out_trade_no": order.OutTradeNo,
"type": order.Channel,
"name": order.Subject,
"money": fenToYuan(order.Money),
"payurl": payurl,
"qrcode": order.Qrcode,
"urlscheme": order.Qrcode,
})
}
}
// PayApiHandler GET/POST /api.php:act=order 查单(pid+key 验签)。
func PayApiHandler(st *store.Store) fun.RouteHandler {
return func(c *fun.RouteCtx) error {
act := strings.TrimSpace(c.Param("act"))
pidStr := strings.TrimSpace(c.Param("pid"))
key := strings.TrimSpace(c.Param("key"))
merPid, merKey, err := payMerchant(st)
if err != nil || pidStr != merPid || key != merKey {
return epayJSON(c, map[string]any{"code": -1, "msg": "商户校验失败"})
}
if act != "order" {
return epayJSON(c, map[string]any{"code": -1, "msg": "不支持的 act"})
}
tradeNo := strings.TrimSpace(c.Param("trade_no"))
outNo := strings.TrimSpace(c.Param("out_trade_no"))
var order struct {
TradeNo string
OutTradeNo string
Subject string
Money int64
Status int64
}
if tradeNo != "" {
r, err := st.Q().GetPayOrderByTradeNo(context.Background(), tradeNo)
if err != nil {
return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"})
}
order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject
order.Money, order.Status = r.Money, r.Status
} else if outNo != "" {
r, err := st.Q().GetPayOrderByOutNo(context.Background(), outNo)
if err != nil {
return epayJSON(c, map[string]any{"code": -1, "msg": "订单不存在"})
}
order.TradeNo, order.OutTradeNo, order.Subject = r.TradeNo, r.OutTradeNo, r.Subject
order.Money, order.Status = r.Money, r.Status
} else {
return epayJSON(c, map[string]any{"code": -1, "msg": "请传入 out_trade_no 或 trade_no"})
}
status := int64(0)
if order.Status == payStatusPaid {
status = 1
}
return epayJSON(c, map[string]any{
"code": 1, "msg": "查询订单号成功",
"trade_no": order.TradeNo,
"out_trade_no": order.OutTradeNo,
"name": order.Subject,
"money": fenToYuan(order.Money),
"status": status,
})
}
}
// epayOrderResult 建单结果(submit/mapi 共用)。
type epayOrderResult struct {
TradeNo string
OutTradeNo string
Channel string
Subject string
Money int64
Payurl string
Qrcode string
}
// epayCreateOrder submit/mapi 共用下单逻辑:参数校验 → 验签 → 幂等建单。
func epayCreateOrder(c *fun.RouteCtx, st *store.Store, up *payx.Client) (epayOrderResult, error) {
var out epayOrderResult
pid := strings.TrimSpace(c.Param("pid"))
merPid, merKey, err := payMerchant(st)
if err != nil || merPid == "" {
return out, errParam("商户未配置")
}
if pid == "" || pid != merPid {
return out, errParam("PID 参数错误")
}
channel := strings.TrimSpace(c.Param("type"))
if channel == "" {
channel = "alipay"
}
if channel != "alipay" {
return out, errParam("当前仅支持支付宝支付(alipay)")
}
outTradeNo := strings.TrimSpace(c.Param("out_trade_no"))
if outTradeNo == "" || len(outTradeNo) > 64 {
return out, errParam("商户订单号 out_trade_no 需为 1-64 位")
}
subject := strings.TrimSpace(c.Param("name"))
if subject == "" || len(subject) > 100 {
return out, errParam("商品名称 name 需为 1-100 个字符")
}
money, err := yuanToFen(c.Param("money"))
if err != nil || money <= 0 || money > payMaxFen {
return out, errParam("订单金额非法(0-2000 元)")
}
notifyURL := strings.TrimSpace(c.Param("notify_url"))
returnURL := strings.TrimSpace(c.Param("return_url"))
for _, u := range []string{notifyURL, returnURL} {
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
return out, errParam("notify_url / return_url 须为合法 URL")
}
}
if !epayVerify(c.Data, merKey, strings.TrimSpace(c.Param("sign"))) {
return out, errParam("签名验证失败")
}
order, err := createPayOrder(st, up, createPayOrderIn{
OutTradeNo: outTradeNo, Channel: channel, Subject: subject,
Money: money, NotifyURL: notifyURL, ReturnURL: returnURL,
ClientIP: c.RequestCtx.RemoteIP().String(),
})
if err != nil {
return out, err
}
out.TradeNo, out.OutTradeNo, out.Channel = order.TradeNo, order.OutTradeNo, order.Channel
out.Subject, out.Money, out.Payurl, out.Qrcode = order.Subject, order.Money, order.Payurl, order.Qrcode
return out, nil
}
func epayJSON(c *fun.RouteCtx, obj map[string]any) error {
raw, err := json.Marshal(obj)
if err != nil {
return err
}
c.RequestCtx.Response.Header.Set("Content-Type", "application/json; charset=utf-8")
c.RequestCtx.Write(raw)
return nil
}
func epayFail(c *fun.RouteCtx, err error) error {
return epayJSON(c, map[string]any{"code": -1, "msg": err.Error()})
}
func errParam(msg string) error { return fun.Error(4001, msg) }
+416
View File
@@ -0,0 +1,416 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/proxyx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// 代理池后台任务参数
const (
proxyCheckInterval = time.Hour // 测活节奏:约每小时一轮
proxyCheckStale = 55 * time.Minute // 距上次检测超过该时长才重测
proxyCheckTimeout = 4 * time.Second // 单次 TCP 拨测超时
proxyCheckWorkers = 128 // 测活并发
proxyPurgeAge = 7 * 24 * time.Hour // 死代理记录保留时长
syncDetailCap = 200 // 同步详情里记录的地址上限
)
var (
proxySyncing atomic.Bool
proxyChecking atomic.Bool
proxyManualSem = make(chan struct{}, 1) // 手动触发同步信号
)
// ProxySvc 代理池:地区分组浏览 + 同步记录;抓取/测活由后台调度器跑
type ProxySvc struct {
fun.Ctx
Store *store.Store
}
func proxyView(p store.Proxy) dto.ProxyView {
return dto.ProxyView{
Id: p.Id, Ip: p.Ip, Port: p.Port, Address: fmt.Sprintf("%s:%d", p.Ip, p.Port),
Protocols: p.Protocols, Anonymity: p.Anonymity, RegionId: p.RegionID,
RegionCode: p.RegionCode, RegionName: p.RegionName, RegionZhName: p.RegionZhName,
Latency: p.Latency, Speed: p.Speed, Uptime: p.Uptime, Alive: p.Alive,
FailCount: p.FailCount, LastCheckedAt: p.LastCheckedAt,
LastAliveAt: p.LastAliveAt, LastSeenAt: p.LastSeenAt,
}
}
func (s *ProxySvc) List(d dto.ProxyListDto) (dto.ProxyListView, error) {
alive := d.Alive
if alive != 0 && alive != 1 {
alive = -1
}
items, total, err := s.Store.ListProxies(store.ProxyFilter{
RegionID: d.RegionId, Protocol: strings.ToUpper(strings.TrimSpace(d.Protocol)),
Keyword: strings.TrimSpace(d.Keyword), Alive: alive,
Page: d.Page, PageSize: d.PageSize,
})
if err != nil {
return dto.ProxyListView{}, err
}
out := dto.ProxyListView{Total: total, Items: make([]dto.ProxyView, 0, len(items))}
for _, p := range items {
out.Items = append(out.Items, proxyView(p))
}
return out, nil
}
func (s *ProxySvc) Regions() ([]dto.ProxyRegionView, error) {
regions, err := s.Store.ListRegions()
if err != nil {
return nil, err
}
out := make([]dto.ProxyRegionView, 0, len(regions))
for _, r := range regions {
out = append(out, dto.ProxyRegionView{
Id: r.Id, Code: r.Code, Name: r.Name, ZhName: r.ZhName,
Region: r.Region, AliveCount: r.AliveCount,
})
}
return out, nil
}
func (s *ProxySvc) Syncs() ([]dto.ProxySyncView, error) {
syncs, err := s.Store.ListProxySyncs(30)
if err != nil {
return nil, err
}
out := make([]dto.ProxySyncView, 0, len(syncs))
for _, v := range syncs {
out = append(out, dto.ProxySyncView{
Id: v.Id, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, Status: v.Status,
Source: v.Source, Total: v.Total, Added: v.Added, Removed: v.Removed,
Updated: v.Updated, DetailJson: v.Detail, Error: v.Error,
})
}
return out, nil
}
func (s *ProxySvc) Stats() (dto.ProxyStatsView, error) {
total, err := s.Store.CountAliveProxies()
if err != nil {
return dto.ProxyStatsView{}, err
}
regions, err := s.Store.ListRegions()
if err != nil {
return dto.ProxyStatsView{}, err
}
v := dto.ProxyStatsView{
Total: total, Regions: int64(len(regions)),
Syncing: proxySyncing.Load(), Checking: proxyChecking.Load(),
LastCheckAt: s.Store.LastProxyCheckAt(),
}
if syncs, err := s.Store.ListProxySyncs(1); err == nil && len(syncs) > 0 {
v.LastSyncAt = syncs[0].FinishedAt
v.LastStatus = syncs[0].Status
}
return v, nil
}
// SyncNow 手动触发一次同步(后台执行,不等结果)
func (s *ProxySvc) SyncNow() (dto.OkView, error) {
select {
case proxyManualSem <- struct{}{}:
default:
}
return dto.OkView{Message: "ok"}, nil
}
// CheckNow 手动触发一轮测活(后台执行)
func (s *ProxySvc) CheckNow() (dto.OkView, error) {
go runProxyCheck(context.Background(), s.Store)
return dto.OkView{Message: "ok"}, nil
}
// ---- 同步与测活 ----
type syncDetail struct {
Added []string `json:"added,omitempty"`
Removed []string `json:"removed,omitempty"`
}
// runProxySync 全量同步:拉取源数据 → 区域 upsert → 代理按 ip:port diff。
// 源上消失的代理直接删除;已存在的刷新指标并复活。
func runProxySync(ctx context.Context, st *store.Store) {
if !proxySyncing.CompareAndSwap(false, true) {
return
}
defer proxySyncing.Store(false)
now := time.Now()
sync := &store.ProxySync{Source: "proxy5", StartedAt: now.Unix()}
syncID, err := st.BeginProxySync(sync.Source, sync.StartedAt)
if err != nil {
log.Printf("proxy: begin sync failed: %v", err)
return
}
sync.Id = syncID
finish := func(status string, detail syncDetail, err error) {
sync.FinishedAt = time.Now().Unix()
sync.Status = status
if b, e := json.Marshal(detail); e == nil {
sync.Detail = string(b)
}
if err != nil {
sync.Error = err.Error()
}
if e := st.FinishProxySync(sync); e != nil {
log.Printf("proxy: finish sync failed: %v", e)
}
}
// 多源抓取:任一源失败不致命,只影响该源名下代理的删除判定
type srcData struct {
name string
remotes []proxyx.Remote
}
sources := []srcData{}
failed := map[string]error{}
if p5, _, err := proxyx.Fetch(ctx); err != nil {
failed["proxy5"] = err
} else {
sources = append(sources, srcData{"proxy5", p5})
}
if rl, err := proxyx.FetchRola(ctx); err != nil {
failed["rola"] = err
} else {
sources = append(sources, srcData{"rola", rl})
}
if len(sources) == 0 {
err := fmt.Errorf("all sources failed: proxy5=%v rola=%v", failed["proxy5"], failed["rola"])
finish("error", syncDetail{}, err)
log.Printf("proxy: %v", err)
return
}
// 按 ip:port 合并各源记录:首个来源保留字段,协议取并集
type merged struct {
r proxyx.Remote
source string
}
pool := map[string]*merged{}
order := []string{}
for _, src := range sources {
for _, r := range src.remotes {
key := fmt.Sprintf("%s:%d", r.IP, r.Port)
if m, ok := pool[key]; ok {
seen := map[string]bool{}
for _, p := range m.r.Protocols {
seen[p] = true
}
for _, p := range r.Protocols {
if !seen[p] {
m.r.Protocols = append(m.r.Protocols, p)
}
}
continue
}
pool[key] = &merged{r: r, source: src.name}
order = append(order, key)
}
}
sync.Total = int64(len(order))
seenKeys := map[string]bool{}
for k := range pool {
seenKeys[k] = true
}
keys, err := st.ProxyKeys()
if err != nil {
finish("error", syncDetail{}, err)
return
}
var detail syncDetail
regionIDs := map[string]int64{}
for _, key := range order {
m := pool[key]
r := m.r
code := strings.ToUpper(strings.TrimSpace(r.CountryCode))
if code == "" {
code = "UN"
}
regionID, ok := regionIDs[code]
if !ok {
regionID, err = st.UpsertRegion(code, r.Country, proxyx.ZhName(code), r.Region, r.CountrySlug)
if err != nil {
continue
}
regionIDs[code] = regionID
}
p := &store.Proxy{
Ip: r.IP, Port: int64(r.Port), Protocols: strings.Join(r.Protocols, ","),
Anonymity: r.Anonymity, Source: m.source, RegionID: regionID,
Latency: int64(r.Latency), Speed: int64(r.Speed), Uptime: int64(r.Uptime),
LastSeenAt: now.Unix(), CreatedAt: now.Unix(),
}
if k, exists := keys[key]; exists {
delete(keys, key)
if err := st.UpdateProxySeen(k.Id, p); err == nil {
sync.Updated++
}
continue
}
if err := st.InsertProxy(p); err == nil {
sync.Added++
if len(detail.Added) < syncDetailCap {
detail.Added = append(detail.Added, key)
}
}
}
// 删除:出现在任一成功源里的都保留;只属于失败源的也保留(可能还在源上)
for key, k := range keys {
if seenKeys[key] {
continue
}
if _, srcFailed := failed[k.Source]; srcFailed {
continue
}
if err := st.DeleteProxy(k.Id); err == nil {
sync.Removed++
if len(detail.Removed) < syncDetailCap {
detail.Removed = append(detail.Removed, key)
}
}
}
var syncErr error
if len(failed) > 0 {
syncErr = fmt.Errorf("partial: %v", failed)
}
finish("ok", detail, syncErr)
log.Printf("proxy: sync done total=%d added=%d updated=%d removed=%d failed=%v",
sync.Total, sync.Added, sync.Updated, sync.Removed, failed)
}
// runProxyCheck 测活一轮:取距上次检测超过阈值的存活代理,并发 TCP 拨测;
// 连不上即标记为死(alive=0),死记录超过保留期后物理清除。
func runProxyCheck(ctx context.Context, st *store.Store) {
if !proxyChecking.CompareAndSwap(false, true) {
return
}
defer proxyChecking.Store(false)
due, err := st.DueProxies(time.Now().Add(-proxyCheckStale).Unix())
if err != nil {
log.Printf("proxy: due list failed: %v", err)
return
}
if len(due) == 0 {
return
}
now := time.Now().Unix()
sem := make(chan struct{}, proxyCheckWorkers)
var wg sync.WaitGroup
for _, p := range due {
wg.Add(1)
go func(p store.DueProxy) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
latency, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, proxyCheckTimeout)
if err != nil {
_ = st.MarkProxyFailed(p.Id, now, p.FailCount+1)
return
}
_ = st.MarkProxyAlive(p.Id, now, now, int64(latency))
}(p)
}
wg.Wait()
if n, err := st.PurgeDeadProxies(time.Now().Add(-proxyPurgeAge).Unix()); err == nil && n > 0 {
log.Printf("proxy: purged %d dead records", n)
}
log.Printf("proxy: checked %d proxies", len(due))
}
// StartProxyScheduler 调度器:启动补一次同步(数据为空时)→ 每小时测活 → 每日 00:00 同步。
func StartProxyScheduler(ctx context.Context, st *store.Store) {
if syncs, err := st.ListProxySyncs(1); err == nil {
if len(syncs) == 0 || time.Since(time.Unix(syncs[0].StartedAt, 0)) > 20*time.Hour {
go runProxySync(ctx, st)
}
}
go func() {
checkTick := time.NewTicker(proxyCheckInterval)
defer checkTick.Stop()
for {
next := nextMidnight()
syncTimer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
syncTimer.Stop()
return
case <-checkTick.C:
runProxyCheck(ctx, st)
case <-proxyManualSem:
go runProxySync(ctx, st)
case <-syncTimer.C:
runProxySync(ctx, st)
}
}
}()
log.Printf("proxy: scheduler started (check hourly, sync daily 00:00)")
}
func nextMidnight() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
}
// ---- 提取密钥管理(admin) ----
func proxyKeyView(k store.ProxyApiKey) dto.ProxyKeyView {
return dto.ProxyKeyView{
Id: k.Id, Key: k.Key, Name: k.Name, RegionId: k.RegionID,
RegionCode: k.RegionCode, RegionZhName: k.RegionZhName,
Enabled: k.Enabled, UsedCount: k.UsedCount,
LastUsedAt: k.LastUsedAt, CreatedAt: k.CreatedAt,
}
}
func (s *ProxySvc) ListKeys() ([]dto.ProxyKeyView, error) {
keys, err := s.Store.ListProxyApiKeys()
if err != nil {
return nil, err
}
out := make([]dto.ProxyKeyView, 0, len(keys))
for _, k := range keys {
out = append(out, proxyKeyView(k))
}
return out, nil
}
// CreateKey 生成一个提取密钥:绑定地区(0=全部),key 随机生成落库
func (s *ProxySvc) CreateKey(d dto.ProxyKeySaveDto) (dto.ProxyKeyView, error) {
key, err := shareCode()
if err != nil {
return dto.ProxyKeyView{}, err
}
key = "pk_" + key + key // 20 位随机串,够长防穷举
id, err := s.Store.InsertProxyApiKey(key, strings.TrimSpace(d.Name), d.RegionId)
if err != nil {
return dto.ProxyKeyView{}, err
}
return dto.ProxyKeyView{Id: id, Key: key, Name: d.Name, RegionId: d.RegionId, Enabled: true}, nil
}
func (s *ProxySvc) DeleteKey(d dto.IdDto) (dto.OkView, error) {
if err := s.Store.DeleteProxyApiKey(d.Id); err != nil {
return dto.OkView{}, err
}
return dto.OkView{Message: "ok"}, nil
}
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/proxyx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
const (
extractBatchSize = 15 // 每批并行探测的候选数
extractBatches = 4 // 最多取几批
extractProbeTO = 3 * time.Second // 单代理探测超时
extractDeadline = 20 * time.Second // 整个请求的最长耗时
)
type extractResult struct {
addr string
lat int
ok bool
}
// ProxyExtractHandler GET /v1/proxy?key=xxx
// 公开提取接口:key 落库校验,地区绑在 key 上(0=全部)。
// 并行探测一批候选,谁先探活返回谁,纯文本 "ip:port";两批全死返回 503。
func ProxyExtractHandler(st *store.Store) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
reqCtx := rc.RequestCtx
key := strings.TrimSpace(string(reqCtx.QueryArgs().Peek("key")))
if key == "" {
routeJSONErr(reqCtx, 401, "missing key")
return nil
}
k, err := st.GetProxyApiKey(key)
if err != nil {
routeJSONErr(reqCtx, 401, "invalid key")
return nil
}
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(extractDeadline))
defer cancel()
for b := 0; b < extractBatches; b++ {
items, err := st.RandomProxies(k.RegionID, "", "", extractBatchSize)
if err != nil {
routeJSONErr(reqCtx, 500, "query failed")
return nil
}
if len(items) == 0 {
break
}
if p, _, ok := probeBatch(ctx, st, items); ok {
st.TouchProxyApiKey(k.Id)
reqCtx.SetContentType("text/plain; charset=utf-8")
reqCtx.SetBodyString(fmt.Sprintf("%s:%d\n", p.Ip, p.Port))
return nil
}
}
routeJSONErr(reqCtx, 503, "no alive proxy")
return nil
}
}
// probeBatch 并行探测一批候选:先活先赢,取消其余;死代理顺手标记移出池。
func probeBatch(ctx context.Context, st *store.Store, items []store.RandomProxy) (store.RandomProxy, int, bool) {
type hit struct {
p store.RandomProxy
lat int
}
hits := make(chan hit, 1)
var wg sync.WaitGroup
for _, p := range items {
wg.Add(1)
go func(p store.RandomProxy) {
defer wg.Done()
lat, err := proxyx.Check(ctx, p.Ip, int(p.Port), p.Protocols, extractProbeTO)
if err != nil {
_ = st.MarkProxyFailed(p.Id, time.Now().Unix(), p.FailCount+1)
return
}
select {
case hits <- hit{p, lat}:
case <-ctx.Done():
}
}(p)
}
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case h := <-hits:
now := time.Now().Unix()
_ = st.MarkProxyAlive(h.p.Id, now, now, int64(h.lat))
return h.p, h.lat, true
case <-done:
return store.RandomProxy{}, 0, false
case <-ctx.Done():
return store.RandomProxy{}, 0, false
}
}
+569
View File
@@ -0,0 +1,569 @@
package service
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"strconv"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/fasthttp/websocket"
"github.com/pkg/sftp"
"github.com/valyala/fasthttp"
"golang.org/x/crypto/ssh"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/sshx"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// ServerSvc 服务器管理:连接密钥加密落库,所有 SSH/SFTP 端点仅管理员。
// 指标采集与文件操作每次新拨号——管理场景连接低频,免去连接池维护成本。
type ServerSvc struct {
fun.Ctx
Store *store.Store
Cfg *config.Config `fun:"auto"`
}
func (s *ServerSvc) List() ([]dto.ServerView, error) {
items, err := s.Store.ListServers()
if err != nil {
return nil, fmt.Errorf("servers: list failed: %w", err)
}
views := make([]dto.ServerView, 0, len(items))
for _, e := range items {
views = append(views, serverView(e))
}
return views, nil
}
func (s *ServerSvc) Save(d dto.SaveServerDto) (dto.ServerView, error) {
name := strings.TrimSpace(d.Name)
host := strings.TrimSpace(d.Host)
username := strings.TrimSpace(d.Username)
if name == "" || len(name) > 64 {
return dto.ServerView{}, fun.Error(4001, "名称必填且不超过 64 字符")
}
if host == "" || len(host) > 255 {
return dto.ServerView{}, fun.Error(4001, "主机地址必填")
}
if username == "" {
return dto.ServerView{}, fun.Error(4001, "用户名必填")
}
authType := "password"
if d.AuthType == "key" {
authType = "key"
}
port := int64(22)
if d.Port != nil {
if *d.Port < 1 || *d.Port > 65535 {
return dto.ServerView{}, fun.Error(4001, "端口范围 1-65535")
}
port = *d.Port
}
e := store.Server{Name: name, Host: host, Port: port, Username: username, AuthType: authType}
if d.Id != nil {
old, err := s.Store.GetServer(*d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.ServerView{}, fun.Error(4001, "服务器不存在")
}
if err != nil {
return dto.ServerView{}, err
}
e = old
e.Name, e.Host, e.Port, e.Username, e.AuthType = name, host, port, username, authType
}
if d.Note != nil {
e.Note = strings.TrimSpace(*d.Note)
}
if d.Secret != nil {
secret := strings.TrimSpace(*d.Secret)
if secret == "" {
e.SecretEnc = ""
} else {
enc, err := vault.Encrypt(s.Cfg.VaultKey, secret)
if err != nil {
return dto.ServerView{}, err
}
e.SecretEnc = enc
}
}
if e.SecretEnc == "" {
return dto.ServerView{}, fun.Error(4001, "请填写密码或私钥")
}
if err := s.Store.SaveServer(&e); err != nil {
return dto.ServerView{}, fmt.Errorf("servers: save failed: %w", err)
}
return serverView(e), nil
}
func (s *ServerSvc) Delete(d dto.ServerIdDto) (dto.OkView, error) {
if err := s.Store.DeleteServer(d.Id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "服务器不存在")
}
return dto.OkView{}, fmt.Errorf("servers: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
// Metrics SSH 拨号 + 采集;拨不通直接返回带因的错误,前端即「离线」
func (s *ServerSvc) Metrics(d dto.ServerIdDto) (dto.ServerMetricsView, error) {
client, err := s.dial(d.Id)
if err != nil {
return dto.ServerMetricsView{}, err
}
defer client.Close()
m, err := sshx.CollectMetrics(client)
if err != nil {
return dto.ServerMetricsView{}, err
}
v := dto.ServerMetricsView{
Hostname: m.Hostname,
Os: m.OS,
Kernel: m.Kernel,
UptimeSeconds: m.UptimeSeconds,
CpuPercent: m.CpuPercent,
Load1: m.Load1,
Load5: m.Load5,
Load15: m.Load15,
MemTotal: m.MemTotal,
MemUsed: m.MemUsed,
MemAvail: m.MemAvail,
SwapTotal: m.SwapTotal,
SwapUsed: m.SwapUsed,
DiskTotal: m.DiskTotal,
DiskUsed: m.DiskUsed,
DiskAvail: m.DiskAvail,
NetRxRate: m.NetRxRate,
NetTxRate: m.NetTxRate,
NetRxTotal: m.NetRxTotal,
NetTxTotal: m.NetTxTotal,
CollectedAt: time.Now().Unix(),
}
for _, dsk := range m.Disks {
v.Disks = append(v.Disks, dto.DiskMountView{
Mount: dsk.Mount, Fs: dsk.Fs, Total: dsk.Total,
Used: dsk.Used, Avail: dsk.Avail, Percent: dsk.Percent,
})
}
return v, nil
}
func (s *ServerSvc) dial(id int64) (*ssh.Client, error) {
e, err := s.Store.GetServer(id)
if errors.Is(err, store.ErrNotFound) {
return nil, fun.Error(4001, "服务器不存在")
}
if err != nil {
return nil, err
}
return dialServer(e, s.Cfg.VaultKey)
}
// dialServer 解密 secret 并拨号;route 处理器与 RPC 共用
func dialServer(e store.Server, key []byte) (*ssh.Client, error) {
if e.SecretEnc == "" {
return nil, fun.Error(4001, "该服务器未配置密码/私钥")
}
secret, err := vault.Decrypt(key, e.SecretEnc)
if err != nil {
return nil, fmt.Errorf("servers: decrypt secret failed: %w", err)
}
return sshx.Dial(e.Host, e.Port, e.Username, e.AuthType, secret)
}
func serverView(e store.Server) dto.ServerView {
v := dto.ServerView{
Id: e.Id,
Name: e.Name,
Host: e.Host,
Port: e.Port,
Username: e.Username,
AuthType: e.AuthType,
HasSecret: e.SecretEnc != "",
CreatedAt: e.CreatedAt,
}
if e.Note != "" {
v.Note = &e.Note
}
return v
}
// ---- SFTP RPC ----
// cleanPath 规整远程路径:缺省 "/",防穿越由 SSH 权限兜底
func cleanPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
return path.Clean(p)
}
func (s *ServerSvc) sftpClient(id int64) (*sftp.Client, *ssh.Client, error) {
c, err := s.dial(id)
if err != nil {
return nil, nil, err
}
fc, err := sftp.NewClient(c)
if err != nil {
c.Close()
return nil, nil, fmt.Errorf("sftp: 初始化失败: %w", err)
}
return fc, c, nil
}
func (s *ServerSvc) SftpList(d dto.SftpPathDto) (dto.SftpListView, error) {
fc, c, err := s.sftpClient(d.Id)
if err != nil {
return dto.SftpListView{}, err
}
defer fc.Close()
defer c.Close()
p := cleanPath(d.Path)
fis, err := fc.ReadDir(p)
if err != nil {
return dto.SftpListView{}, fun.Error(4001, "无法读取目录:"+err.Error())
}
v := dto.SftpListView{Path: p, Entries: []dto.SftpEntryView{}}
for _, fi := range fis {
v.Entries = append(v.Entries, dto.SftpEntryView{
Name: fi.Name(),
Size: fi.Size(),
Mode: fi.Mode().String(),
IsDir: fi.IsDir(),
ModTime: fi.ModTime().Unix(),
})
}
return v, nil
}
func (s *ServerSvc) SftpMkdir(d dto.SftpPathDto) (dto.OkView, error) {
fc, c, err := s.sftpClient(d.Id)
if err != nil {
return dto.OkView{}, err
}
defer fc.Close()
defer c.Close()
if err := fc.Mkdir(cleanPath(d.Path)); err != nil {
return dto.OkView{}, fun.Error(4001, "创建目录失败:"+err.Error())
}
return dto.OkView{Message: "已创建"}, nil
}
// SftpRemove 删文件或空目录;非空目录拒绝递归删除,避免误删整棵目录树
func (s *ServerSvc) SftpRemove(d dto.SftpPathDto) (dto.OkView, error) {
fc, c, err := s.sftpClient(d.Id)
if err != nil {
return dto.OkView{}, err
}
defer fc.Close()
defer c.Close()
p := cleanPath(d.Path)
fi, err := fc.Stat(p)
if err != nil {
return dto.OkView{}, fun.Error(4001, "路径不存在")
}
if fi.IsDir() {
err = fc.RemoveDirectory(p)
} else {
err = fc.Remove(p)
}
if err != nil {
return dto.OkView{}, fun.Error(4001, "删除失败:"+err.Error()+"(目录需为空)")
}
return dto.OkView{Message: "已删除"}, nil
}
func (s *ServerSvc) SftpRename(d dto.SftpRenameDto) (dto.OkView, error) {
newName := strings.TrimSpace(d.NewName)
if newName == "" || strings.ContainsAny(newName, "/\\") {
return dto.OkView{}, fun.Error(4001, "新名字无效")
}
fc, c, err := s.sftpClient(d.Id)
if err != nil {
return dto.OkView{}, err
}
defer fc.Close()
defer c.Close()
old := cleanPath(d.Path)
if err := fc.PosixRename(old, path.Join(path.Dir(old), newName)); err != nil {
return dto.OkView{}, fun.Error(4001, "重命名失败:"+err.Error())
}
return dto.OkView{Message: "已重命名"}, nil
}
// ---- 自定义路由:上传 / 下载 / WS 终端 ----
// routeAdmin 自定义路由不走 AuthGuard,手动校验 query token 且要求管理员
func routeAdmin(st *store.Store, ctx *fasthttp.RequestCtx) error {
token := string(ctx.QueryArgs().Peek("token"))
if token == "" {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
return errors.New("missing token")
}
su, err := st.GetSessionUser(token, sessionTTL, sessionRenewAfter)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusUnauthorized)
return errors.New("invalid token")
}
if su.User.Role != "admin" || su.User.Status != "active" {
ctx.SetStatusCode(fasthttp.StatusForbidden)
return errors.New("admin only")
}
return nil
}
func routeServer(st *store.Store, ctx *fasthttp.RequestCtx) (store.Server, error) {
id, _ := strconv.ParseInt(string(ctx.QueryArgs().Peek("id")), 10, 64)
e, err := st.GetServer(id)
if err != nil {
ctx.SetStatusCode(fasthttp.StatusNotFound)
ctx.SetBodyString("server not found")
}
return e, err
}
// SftpDownloadHandler GET /files/download?id=&path=&token= 流式回传文件
func SftpDownloadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
if err := routeAdmin(st, ctx); err != nil {
return nil
}
e, err := routeServer(st, ctx)
if err != nil {
return nil
}
client, err := dialServer(e, cfg.VaultKey)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString("SSH 连接失败:" + err.Error())
return nil
}
fc, err := sftp.NewClient(client)
if err != nil {
client.Close()
ctx.SetStatusCode(502)
ctx.SetBodyString("SFTP 初始化失败")
return nil
}
p := cleanPath(string(ctx.QueryArgs().Peek("path")))
f, err := fc.Open(p)
if err != nil {
fc.Close()
client.Close()
ctx.SetStatusCode(404)
ctx.SetBodyString("文件不可读:" + err.Error())
return nil
}
if fi, err := f.Stat(); err == nil {
ctx.Response.Header.Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
}
name := path.Base(p)
ctx.Response.Header.Set("Content-Disposition", "attachment; filename*=UTF-8''"+urlPathEscape(name))
ctx.SetContentType("application/octet-stream")
// 流式回传:连接生命周期交给 StreamWriter 收尾
ctx.SetBodyStreamWriter(func(w *bufio.Writer) {
_, _ = io.Copy(w, f)
f.Close()
fc.Close()
client.Close()
})
return nil
}
}
func urlPathEscape(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
if c < 0x20 || c > 0x7e || strings.ContainsRune("\"%;+", rune(c)) {
fmt.Fprintf(&b, "%%%02X", c)
} else {
b.WriteByte(c)
}
}
return b.String()
}
// SftpUploadHandler POST /files/upload?id=&dir=&token= multipart 字段 file
func SftpUploadHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
if err := routeAdmin(st, ctx); err != nil {
ctx.SetBodyString(`{"status":2,"msg":"未授权"}`)
return nil
}
e, err := routeServer(st, ctx)
if err != nil {
return nil
}
fh, err := ctx.FormFile("file")
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"缺少文件"}`)
return nil
}
src, err := fh.Open()
if err != nil {
ctx.SetStatusCode(400)
ctx.SetBodyString(`{"status":2,"msg":"读取上传失败"}`)
return nil
}
defer src.Close()
client, err := dialServer(e, cfg.VaultKey)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString(`{"status":2,"msg":"SSH 连接失败"}`)
return nil
}
defer client.Close()
fc, err := sftp.NewClient(client)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString(`{"status":2,"msg":"SFTP 初始化失败"}`)
return nil
}
defer fc.Close()
dir := cleanPath(string(ctx.QueryArgs().Peek("dir")))
dst := path.Join(dir, path.Base(fh.Filename))
w, err := fc.Create(dst)
if err != nil {
ctx.SetStatusCode(500)
ctx.SetBodyString(`{"status":2,"msg":"创建远端文件失败:` + err.Error() + `"}`)
return nil
}
if _, err := io.Copy(w, src); err != nil {
w.Close()
ctx.SetStatusCode(500)
ctx.SetBodyString(`{"status":2,"msg":"写入失败:` + err.Error() + `"}`)
return nil
}
w.Close()
ctx.SetContentType("application/json")
ctx.SetBodyString(`{"status":0,"msg":"ok"}`)
return nil
}
}
// TerminalHandler GET /ws/terminal?id=&token= 升级 WebSocket → SSH PTY。
// 协议:客户端→服务端首字节 0x01 为 JSON 控制帧 {"cols":N,"rows":N}(resize),
// 其余原样写入 shell stdin;服务端→客户端全部原样回显。
func TerminalHandler(st *store.Store, cfg *config.Config) fun.RouteHandler {
upgrader := websocket.FastHTTPUpgrader{
CheckOrigin: func(*fasthttp.RequestCtx) bool { return true },
}
return func(rc *fun.RouteCtx) error {
ctx := rc.RequestCtx
if err := routeAdmin(st, ctx); err != nil {
ctx.SetBodyString("unauthorized")
return nil
}
e, err := routeServer(st, ctx)
if err != nil {
return nil
}
client, err := dialServer(e, cfg.VaultKey)
if err != nil {
ctx.SetStatusCode(502)
ctx.SetBodyString("SSH 连接失败:" + err.Error())
return nil
}
// Upgrade 内部 hijack 连接后异步跑回调;ssh.Client 生命周期归 runTerm
if err := upgrader.Upgrade(ctx, func(ws *websocket.Conn) {
runTerm(ws, client)
}); err != nil {
client.Close()
return nil
}
return nil
}
}
type termResize struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
}
func runTerm(ws *websocket.Conn, client *ssh.Client) {
defer ws.Close()
defer client.Close()
sess, err := client.NewSession()
if err != nil {
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nopen session failed: "+err.Error()+"\r\n"))
return
}
defer sess.Close()
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil {
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\npty failed: "+err.Error()+"\r\n"))
return
}
stdin, err := sess.StdinPipe()
if err != nil {
return
}
stdout, err := sess.StdoutPipe()
if err != nil {
return
}
sess.Stderr = sess.Stdout
if err := sess.Shell(); err != nil {
_ = ws.WriteMessage(websocket.TextMessage, []byte("\r\nshell failed: "+err.Error()+"\r\n"))
return
}
done := make(chan struct{})
// ws → stdin
go func() {
defer close(done)
for {
_, data, err := ws.ReadMessage()
if err != nil {
_ = sess.Close()
return
}
if len(data) > 0 && data[0] == 0x01 {
var r termResize
if json.Unmarshal(data[1:], &r) == nil && r.Cols > 0 && r.Rows > 0 {
_ = sess.WindowChange(r.Rows, r.Cols)
}
continue
}
if _, err := stdin.Write(data); err != nil {
return
}
}
}()
// stdout → ws
buf := make([]byte, 32*1024)
for {
n, err := stdout.Read(buf)
if n > 0 {
if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
break
}
}
if err != nil {
break
}
}
_ = sess.Close()
<-done
}
+39
View File
@@ -0,0 +1,39 @@
package service
import (
"strings"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// SettingSvc 站点级 KV 设置:读取对所有登录用户开放,写入仅管理员
type SettingSvc struct {
fun.Ctx
Store *store.Store
}
func (s *SettingSvc) Get(d dto.SettingDto) (dto.SettingView, error) {
key := strings.TrimSpace(d.Key)
if key == "" || len(key) > 64 {
return dto.SettingView{}, fun.Error(4001, "key 必填且不超过 64 字符")
}
v, err := s.Store.GetSetting(key)
if err != nil {
return dto.SettingView{}, err
}
return dto.SettingView{Value: v}, nil
}
func (s *SettingSvc) Set(d dto.SettingDto) (dto.OkView, error) {
key := strings.TrimSpace(d.Key)
if key == "" || len(key) > 64 || len(d.Value) > 2000 {
return dto.OkView{}, fun.Error(4001, "key 必填且不超过 64 字符,value 不超过 2000 字符")
}
if err := s.Store.SetSetting(key, strings.TrimSpace(d.Value)); err != nil {
return dto.OkView{}, err
}
return dto.OkView{Message: "已保存"}, nil
}
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"fmt"
"strings"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// ToolSvc 工具箱注册表:List 登录用户可见(驱动侧边导航),管理端点为 admin
type ToolSvc struct {
fun.Ctx
Store *store.Store
}
func (s *ToolSvc) List() ([]dto.ToolView, error) {
tools, err := s.Store.ListTools(true)
if err != nil {
return nil, fmt.Errorf("tool: list failed: %w", err)
}
return toolViews(tools), nil
}
func (s *ToolSvc) AdminList() ([]dto.ToolView, error) {
tools, err := s.Store.ListTools(false)
if err != nil {
return nil, fmt.Errorf("tool: list failed: %w", err)
}
return toolViews(tools), nil
}
func (s *ToolSvc) SaveTool(d dto.SaveToolDto) (dto.ToolView, error) {
name := strings.TrimSpace(d.Name)
url := strings.TrimSpace(d.Url)
if name == "" || len(name) > 32 {
return dto.ToolView{}, fun.Error(4001, "工具名必填且不超过 32 字符")
}
t := store.Tool{
Name: name,
Url: url,
Sort: 0,
Enabled: true,
}
if d.Id != nil {
t.Id = *d.Id
}
if d.Icon != nil {
t.Icon = strings.TrimSpace(*d.Icon)
}
if d.Description != nil {
t.Description = strings.TrimSpace(*d.Description)
}
if d.Sort != nil {
t.Sort = *d.Sort
}
if d.Enabled != nil {
t.Enabled = *d.Enabled
}
if err := s.Store.SaveTool(&t); err != nil {
return dto.ToolView{}, fmt.Errorf("tool: save failed: %w", err)
}
views := toolViews([]store.Tool{t})
return views[0], nil
}
func (s *ToolSvc) DeleteTool(d dto.DeleteToolDto) (dto.OkView, error) {
if err := s.Store.DeleteTool(d.Id); err != nil {
return dto.OkView{}, fmt.Errorf("tool: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
func toolViews(tools []store.Tool) []dto.ToolView {
views := make([]dto.ToolView, 0, len(tools))
for _, t := range tools {
views = append(views, dto.ToolView{
Id: t.Id,
Name: t.Name,
Icon: t.Icon,
Url: t.Url,
Description: t.Description,
Sort: t.Sort,
Enabled: t.Enabled,
})
}
return views
}
+147
View File
@@ -0,0 +1,147 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/cyi-cc/fun"
"golang.org/x/crypto/bcrypt"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
)
// UserSvc 用户管理:全部 policyAdmin, Guard 已保证仅管理员可达
type UserSvc struct {
fun.Ctx
Store *store.Store
}
func (s *UserSvc) ListUsers(d dto.ListUsersDto) (dto.UserPageView, error) {
page := max(d.Page, 1)
pageSize := min(max(d.PageSize, 1), 100)
keyword := ""
if d.Keyword != nil {
keyword = strings.TrimSpace(*d.Keyword)
}
users, total, err := s.Store.ListUsers(keyword, page, pageSize)
if err != nil {
return dto.UserPageView{}, fmt.Errorf("user: list failed: %w", err)
}
items := make([]dto.UserView, 0, len(users))
for _, u := range users {
items = append(items, userView(u))
}
return dto.UserPageView{Total: total, Items: items}, nil
}
// SaveUser Id 为空创建(密码必填),非空更新(密码留空不变)
func (s *UserSvc) SaveUser(d dto.SaveUserDto) (dto.UserView, error) {
name := strings.TrimSpace(d.Name)
if name == "" || len(name) > 32 {
return dto.UserView{}, fun.Error(4001, "用户名必填且不超过 32 字符")
}
if d.Role != "admin" && d.Role != "user" {
return dto.UserView{}, fun.Error(4001, "角色无效")
}
if d.Id == nil {
if d.Password == nil || len(*d.Password) < 6 {
return dto.UserView{}, fun.Error(4001, "密码至少 6 位")
}
if _, err := s.Store.GetUserByName(name); err == nil {
return dto.UserView{}, fun.Error(4001, "用户名已存在")
} else if !errors.Is(err, store.ErrNotFound) {
return dto.UserView{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost)
if err != nil {
return dto.UserView{}, err
}
u, err := s.Store.CreateUser(name, string(hash), d.Role)
if err != nil {
return dto.UserView{}, fmt.Errorf("user: create failed: %w", err)
}
return userView(u), nil
}
u, err := s.Store.GetUserByID(*d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.UserView{}, fun.Error(4001, "用户不存在")
}
if err != nil {
return dto.UserView{}, err
}
if existing, err := s.Store.GetUserByName(name); err == nil && existing.Id != u.Id {
return dto.UserView{}, fun.Error(4001, "用户名已存在")
}
status := u.Status
if d.Status != nil {
if *d.Status != "active" && *d.Status != "disabled" {
return dto.UserView{}, fun.Error(4001, "状态无效")
}
status = *d.Status
}
if err := s.guardLastAdmin(u, d.Role, status); err != nil {
return dto.UserView{}, err
}
var hash *string
if d.Password != nil && *d.Password != "" {
if len(*d.Password) < 6 {
return dto.UserView{}, fun.Error(4001, "密码至少 6 位")
}
h, err := bcrypt.GenerateFromPassword([]byte(*d.Password), bcrypt.DefaultCost)
if err != nil {
return dto.UserView{}, err
}
hs := string(h)
hash = &hs
}
if err := s.Store.UpdateUser(u.Id, name, d.Role, status, hash); err != nil {
return dto.UserView{}, fmt.Errorf("user: update failed: %w", err)
}
u, err = s.Store.GetUserByID(u.Id)
if err != nil {
return dto.UserView{}, err
}
return userView(u), nil
}
func (s *UserSvc) DeleteUser(d dto.DeleteUserDto) (dto.OkView, error) {
su, err := sessionFrom(s.RequestCtx)
if err != nil {
return dto.OkView{}, err
}
if d.Id == su.User.Id {
return dto.OkView{}, fun.Error(4001, "不能删除当前登录账号")
}
u, err := s.Store.GetUserByID(d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "用户不存在")
}
if err != nil {
return dto.OkView{}, err
}
if err := s.guardLastAdmin(u, "user", "disabled"); err != nil {
return dto.OkView{}, err
}
if err := s.Store.DeleteUser(d.Id); err != nil {
return dto.OkView{}, fmt.Errorf("user: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
// guardLastAdmin 阻止把最后一个可用管理员降级/禁用/删除
func (s *UserSvc) guardLastAdmin(u store.User, newRole, newStatus string) error {
if u.Role == "admin" && u.Status == "active" && (newRole != "admin" || newStatus != "active") {
n, err := s.Store.CountAdmins()
if err != nil {
return err
}
if n <= 1 {
return fun.Error(4001, "至少需要保留一个可用管理员")
}
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"errors"
"fmt"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/cyi-box/backend/internal/config"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
"github.com/cyi-cc/cyi-box/backend/internal/store"
"github.com/cyi-cc/cyi-box/backend/internal/vault"
)
// VaultSvc 密码箱:密文只在服务端出现;列表只回元数据,
// 密码经 Reveal 显式取回,2FA 只回当前验证码不回密钥。
type VaultSvc struct {
fun.Ctx
Store *store.Store
Cfg *config.Config `fun:"auto"`
}
func (s *VaultSvc) owner() (store.SessionUser, error) {
return sessionFrom(s.RequestCtx)
}
func (s *VaultSvc) List() ([]dto.VaultItemView, error) {
su, err := s.owner()
if err != nil {
return nil, err
}
items, err := s.Store.ListVault(su.User.Id, "")
if err != nil {
return nil, fmt.Errorf("vault: list failed: %w", err)
}
views := make([]dto.VaultItemView, 0, len(items))
for _, e := range items {
views = append(views, vaultView(e))
}
return views, nil
}
func (s *VaultSvc) Save(d dto.SaveVaultDto) (dto.VaultItemView, error) {
su, err := s.owner()
if err != nil {
return dto.VaultItemView{}, err
}
title := strings.TrimSpace(d.Title)
if title == "" || len(title) > 64 {
return dto.VaultItemView{}, fun.Error(4001, "标题必填且不超过 64 字符")
}
e := store.VaultEntry{UserID: su.User.Id, Title: title}
if d.Id != nil {
old, err := s.Store.GetVault(su.User.Id, *d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.VaultItemView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.VaultItemView{}, err
}
e = old
e.Title = title
}
if d.Username != nil {
e.Username = strings.TrimSpace(*d.Username)
}
if d.Url != nil {
e.Url = strings.TrimSpace(*d.Url)
}
if d.Note != nil {
e.Note = strings.TrimSpace(*d.Note)
}
if d.Password != nil {
enc, err := vault.Encrypt(s.Cfg.VaultKey, *d.Password)
if err != nil {
return dto.VaultItemView{}, err
}
e.PasswordEnc = enc
}
if d.TotpSecret != nil {
secret := strings.TrimSpace(*d.TotpSecret)
if secret == "" {
e.TotpEnc = "" // 空字符串 = 清除 2FA
} else {
parsed, err := vault.ParseSecret(secret)
if err != nil {
return dto.VaultItemView{}, fun.Error(4001, "2FA 密钥无效:支持 base32 或 otpauth:// 链接")
}
enc, err := vault.Encrypt(s.Cfg.VaultKey, parsed)
if err != nil {
return dto.VaultItemView{}, err
}
e.TotpEnc = enc
}
}
if err := s.Store.SaveVault(&e); err != nil {
return dto.VaultItemView{}, fmt.Errorf("vault: save failed: %w", err)
}
return vaultView(e), nil
}
// Reveal 显式取回明文密码(前端点「查看」才调用,列表永不携带)
func (s *VaultSvc) Reveal(d dto.VaultIdDto) (dto.SecretView, error) {
su, err := s.owner()
if err != nil {
return dto.SecretView{}, err
}
e, err := s.Store.GetVault(su.User.Id, d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.SecretView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.SecretView{}, err
}
if e.PasswordEnc == "" {
return dto.SecretView{}, fun.Error(4001, "该条目没有密码")
}
pt, err := vault.Decrypt(s.Cfg.VaultKey, e.PasswordEnc)
if err != nil {
return dto.SecretView{}, fmt.Errorf("vault: decrypt failed: %w", err)
}
return dto.SecretView{Value: pt}, nil
}
// Totp 返回当前 6 位验证码与剩余秒数;密钥不出服务端
func (s *VaultSvc) Totp(d dto.VaultIdDto) (dto.TotpView, error) {
su, err := s.owner()
if err != nil {
return dto.TotpView{}, err
}
e, err := s.Store.GetVault(su.User.Id, d.Id)
if errors.Is(err, store.ErrNotFound) {
return dto.TotpView{}, fun.Error(4001, "条目不存在")
}
if err != nil {
return dto.TotpView{}, err
}
if e.TotpEnc == "" {
return dto.TotpView{}, fun.Error(4001, "该条目未设置 2FA")
}
secret, err := vault.Decrypt(s.Cfg.VaultKey, e.TotpEnc)
if err != nil {
return dto.TotpView{}, fmt.Errorf("vault: decrypt totp failed: %w", err)
}
code, left, err := vault.TOTP(secret, time.Now())
if err != nil {
return dto.TotpView{}, fun.Error(4001, "2FA 密钥无效")
}
return dto.TotpView{Code: code, SecondsLeft: left}, nil
}
func (s *VaultSvc) Delete(d dto.VaultIdDto) (dto.OkView, error) {
su, err := s.owner()
if err != nil {
return dto.OkView{}, err
}
if err := s.Store.DeleteVault(su.User.Id, d.Id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return dto.OkView{}, fun.Error(4001, "条目不存在")
}
return dto.OkView{}, fmt.Errorf("vault: delete failed: %w", err)
}
return dto.OkView{Message: "已删除"}, nil
}
func vaultView(e store.VaultEntry) dto.VaultItemView {
v := dto.VaultItemView{
Id: e.Id,
Title: e.Title,
HasPassword: e.PasswordEnc != "",
HasTotp: e.TotpEnc != "",
UpdatedAt: e.UpdatedAt,
}
if e.Username != "" {
v.Username = &e.Username
}
if e.Url != "" {
v.Url = &e.Url
}
if e.Note != "" {
v.Note = &e.Note
}
return v
}
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"encoding/json"
"strings"
"time"
"github.com/cyi-cc/fun"
"github.com/valyala/fasthttp"
"github.com/cyi-cc/cyi-box/backend/internal/dto"
)
const (
webReqTimeout = 30 * time.Second
webRespMaxBody = 2 << 20 // 响应体截断上限 2MB
webReqMaxBody = 1 << 20 // 请求体上限 1MB
webMaxRedirect = 5
webMaxHeaderPairs = 64
)
// fun autowired 只处理导出字段,client 用包级单例复用
var webClient = &fasthttp.Client{
Name: "cyi-box-webreq",
MaxConnsPerHost: 16,
ReadTimeout: webReqTimeout,
WriteTimeout: webReqTimeout,
MaxConnWaitTimeout: 5 * time.Second,
}
// WebSvc HTTP 请求代理(Postman 工具):服务端代发以绕开浏览器 CORS。
// 只放行 http/https;headersJson 为 {"k":"v"} 对象。
type WebSvc struct {
fun.Ctx
}
func (s *WebSvc) Fetch(d dto.WebFetchDto) (dto.WebFetchView, error) {
method := strings.ToUpper(strings.TrimSpace(d.Method))
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS":
default:
return dto.WebFetchView{}, fun.Error(4001, "不支持的请求方法: "+method)
}
url := strings.TrimSpace(d.Url)
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return dto.WebFetchView{}, fun.Error(4001, "URL 必须以 http:// 或 https:// 开头")
}
if len(d.Body) > webReqMaxBody {
return dto.WebFetchView{}, fun.Error(4001, "请求体超过 1MB 上限")
}
var headers map[string]string
if strings.TrimSpace(d.HeadersJson) != "" {
if err := json.Unmarshal([]byte(d.HeadersJson), &headers); err != nil {
return dto.WebFetchView{}, fun.Error(4001, "请求头 JSON 解析失败: "+err.Error())
}
if len(headers) > webMaxHeaderPairs {
return dto.WebFetchView{}, fun.Error(4001, "请求头数量超过上限")
}
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.Header.SetMethod(method)
req.SetRequestURI(url)
for k, v := range headers {
k = strings.TrimSpace(k)
if k == "" || strings.EqualFold(k, "Host") || strings.EqualFold(k, "Content-Length") {
continue
}
req.Header.Set(k, v)
}
if d.Body != "" {
req.SetBodyString(d.Body)
}
start := time.Now()
err := webClient.DoRedirects(req, resp, webMaxRedirect)
elapsed := time.Since(start).Milliseconds()
if err != nil {
return dto.WebFetchView{}, fun.Error(4002, "请求失败: "+err.Error())
}
view := dto.WebFetchView{
Status: int64(resp.StatusCode()),
ElapsedMs: elapsed,
}
respHeaders := map[string]string{}
resp.Header.VisitAll(func(k, v []byte) {
respHeaders[string(k)] = string(v)
})
if b, e := json.Marshal(respHeaders); e == nil {
view.HeadersJson = string(b)
}
body := resp.Body()
if len(body) > webRespMaxBody {
body = body[:webRespMaxBody]
view.Truncated = true
}
view.Body = string(body)
return view, nil
}