zhangjidong 3 päivää sitten
sitoutus
ed4e5a02b9

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/conf
+.claude

+ 213 - 0
controllers/admin/BaseController.go

@@ -0,0 +1,213 @@
+package admin
+
+import (
+	"strconv"
+	"time"
+
+	beego "github.com/beego/beego/v2/server/web"
+	"github.com/dgrijalva/jwt-go"
+)
+
+// JWT配置
+var (
+	JWTSecret   = []byte("your-secret-key") // 生产环境应该使用更复杂的密钥
+	TokenExpire = 7200                      // Token有效期,单位秒(2小时)
+)
+
+// 用户信息结构
+type UserInfo struct {
+	Id       int    `json:"id"`
+	Username string `json:"username"`
+	Role     string `json:"role"`
+	// 其他用户信息字段...
+}
+
+// JWT Claims结构
+type Claims struct {
+	UserInfo UserInfo `json:"user_info"`
+	jwt.StandardClaims
+}
+
+// BaseController 基础控制器,提供JWT鉴权和权限控制
+type BaseController struct {
+	beego.Controller
+	UserInfo UserInfo // 当前登录用户信息
+	IsLogin  bool     // 是否已登录
+}
+
+// 生成Token
+func GenerateToken(userInfo UserInfo) (string, error) {
+	expireTime := time.Now().Add(time.Duration(TokenExpire) * time.Second)
+	claims := &Claims{
+		UserInfo: userInfo,
+		StandardClaims: jwt.StandardClaims{
+			ExpiresAt: expireTime.Unix(),
+			IssuedAt:  time.Now().Unix(),
+		},
+	}
+
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	return token.SignedString(JWTSecret)
+}
+
+// 解析Token
+func ParseToken(tokenString string) (*Claims, error) {
+	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
+		return JWTSecret, nil
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
+		return claims, nil
+	}
+
+	return nil, err
+}
+
+// 验证Token中间件
+func (c *BaseController) VerifyToken() {
+	tokenString := c.Ctx.Input.Header("Authorization")
+	if tokenString == "" {
+		c.Error("Authorization token required", 401)
+		return
+	}
+
+	claims, err := ParseToken(tokenString)
+	if err != nil {
+		c.Error("Invalid token: "+err.Error(), 401)
+		return
+	}
+
+	// 检查Token是否过期
+	if time.Now().Unix() > claims.ExpiresAt {
+		c.Error("Token expired", 401)
+		return
+	}
+
+	// 设置用户信息
+	c.UserInfo = claims.UserInfo
+	c.IsLogin = true
+}
+
+// 权限检查函数类型
+type PermissionCheckFunc func(userInfo UserInfo, permission string) bool
+
+// 默认权限检查函数
+func DefaultPermissionCheck(userInfo UserInfo, permission string) bool {
+	// 这里实现具体的权限逻辑
+	// 例如:检查用户角色是否有所需权限
+	// 简单示例:admin角色有所有权限
+	if userInfo.Role == "admin" {
+		return true
+	}
+	// 可以根据具体权限字符串检查
+	// 实际项目中应该查询数据库或缓存中的权限配置
+	return false
+}
+
+// 检查权限中间件
+func (c *BaseController) CheckPermission(permission string, checkFunc ...PermissionCheckFunc) {
+	if !c.IsLogin {
+		c.Error("Permission denied, user not logged in", 403)
+		return
+	}
+
+	var check PermissionCheckFunc
+	if len(checkFunc) > 0 {
+		check = checkFunc[0]
+	} else {
+		check = DefaultPermissionCheck
+	}
+
+	if !check(c.UserInfo, permission) {
+		c.Error("Permission denied for user: "+c.UserInfo.Username, 403)
+		return
+	}
+}
+
+// 返回JSON响应
+func (c *BaseController) JSONResponse(data interface{}, errCode ...int) {
+	code := 200
+	if len(errCode) > 0 {
+		code = errCode[0]
+	}
+
+	response := map[string]interface{}{
+		"code": code,
+		"data": data,
+		"msg":  getMessageByCode(code),
+	}
+
+	c.Data["json"] = response
+	c.ServeJSON()
+}
+
+// 成功响应
+func (c *BaseController) Success(data interface{}, msg ...string) {
+	message := "success"
+	if len(msg) > 0 {
+		message = msg[0]
+	}
+	response := map[string]interface{}{
+		"code": 200,
+		"data": data,
+		"msg":  message,
+	}
+	c.Data["json"] = response
+	c.ServeJSON()
+}
+
+// 失败响应
+func (c *BaseController) Error(msg string, code ...int) {
+	errCode := 400
+	if len(code) > 0 {
+		errCode = code[0]
+	}
+	response := map[string]interface{}{
+		"code": errCode,
+		"data": nil,
+		"msg":  msg,
+	}
+	c.Data["json"] = response
+	c.ServeJSON()
+}
+
+// 获取分页参数
+func (c *BaseController) GetPageParams() (page, pageSize int) {
+	page, _ = strconv.Atoi(c.Ctx.Input.Query("page"))
+	if page < 1 {
+		page = 1
+	}
+	pageSize, _ = strconv.Atoi(c.Ctx.Input.Query("pageSize"))
+	if pageSize < 1 {
+		pageSize = 20 // 默认每页20条
+	}
+	return page, pageSize
+}
+
+// Prepare 在执行任何HTTP方法之前调用
+func (c *BaseController) Prepare() {
+	// 自动验证Token(除了登录接口)
+	if c.Ctx.Input.URL() != "/admin/login" {
+		c.VerifyToken()
+	}
+}
+
+// getMessageByCode 根据状态码获取消息
+func getMessageByCode(code int) string {
+	messages := map[int]string{
+		200: "success",
+		400: "bad request",
+		401: "unauthorized",
+		403: "forbidden",
+		404: "not found",
+		500: "internal server error",
+	}
+	if msg, ok := messages[code]; ok {
+		return msg
+	}
+	return "unknown error"
+}

+ 47 - 0
controllers/admin/CoinController.go

@@ -0,0 +1,47 @@
+package admin
+
+import (
+	"strconv"
+	"think-go/controllers/services"
+	"think-go/utils"
+
+	beego "github.com/beego/beego/v2/server/web"
+)
+
+type CoinController struct {
+	beego.Controller
+}
+
+// KlineView page
+func (c *CoinController) KlineView() {
+	c.TplName = "admin/kline.tpl"
+}
+
+// GET /admin/coin/kline?symbol=BTCUSDT&interval=hour&limit=200
+func (c *CoinController) Kline() {
+	symbol := c.GetString("symbol")
+	if symbol == "" {
+		symbol = "BTCUSDT"
+	}
+	interval := c.GetString("interval")
+	if interval == "" {
+		interval = "hour"
+	}
+
+	limit, _ := strconv.Atoi(c.GetString("limit"))
+	startTime, _ := strconv.ParseInt(c.GetString("start_time"), 10, 64)
+	endTime, _ := strconv.ParseInt(c.GetString("end_time"), 10, 64)
+
+	svc := &services.KlineService{}
+	data, err := svc.FetchKlines(symbol, interval, limit, startTime, endTime)
+	if err != nil {
+		utils.JSON(&c.Controller, 201, "error", err.Error())
+	}
+
+	utils.JSON(&c.Controller, 200, "success", map[string]interface{}{
+		"symbol":   symbol,
+		"interval": interval,
+		"count":    len(data),
+		"items":    data,
+	})
+}

+ 12 - 0
controllers/admin/GameController.go

@@ -0,0 +1,12 @@
+package admin
+
+import beego "github.com/beego/beego/v2/server/web"
+
+type GameController struct {
+	beego.Controller
+}
+
+// Tetris page
+func (c *GameController) Tetris() {
+	c.TplName = "admin/tetris.tpl"
+}

+ 43 - 0
controllers/admin/LoginController.go

@@ -0,0 +1,43 @@
+package admin
+
+import (
+	"think-go/controllers/services"
+	"think-go/utils"
+
+	"github.com/beego/beego/v2/server/web"
+)
+
+type LoginController struct {
+	web.Controller
+}
+
+// @router /login/login [get,post]
+func (c *LoginController) Login() {
+	if c.Ctx.Input.IsGet() {
+		utils.JSON(&c.Controller, 200, "success", "please use POST to login")
+	}
+
+	mobile, err := utils.GetRequestString(&c.Controller, "mobile")
+	if err != nil {
+		utils.JSON(&c.Controller, 201, "error", "invalid json body")
+	}
+	password, err := utils.GetRequestString(&c.Controller, "password")
+	if err != nil {
+		utils.JSON(&c.Controller, 201, "error", "invalid json body")
+	}
+
+	if mobile == "" {
+		utils.JSON(&c.Controller, 201, "error", "mobile is required")
+	}
+	if password == "" {
+		utils.JSON(&c.Controller, 201, "error", "password is required")
+	}
+
+	svc := &services.SaasUserService{}
+	result, err := svc.Login(mobile, password)
+	if err != nil {
+		utils.JSON(&c.Controller, 201, "error", err.Error())
+	}
+
+	utils.JSON(&c.Controller, 200, "success", result)
+}

+ 39 - 0
controllers/admin/UserController.go

@@ -0,0 +1,39 @@
+package admin
+
+import (
+	"strings"
+	"think-go/controllers/services"
+)
+
+type UserController struct {
+	BaseController
+}
+
+// @router /user/add [get,post]
+func (c *UserController) Add() {
+	c.Ctx.WriteString("this is add user")
+}
+
+// @router /user/:id [get]
+func (c *UserController) GetUser() {
+	mobile := c.Ctx.Input.Param(":id")
+	password := c.GetString("password")
+
+	if strings.TrimSpace(mobile) == "" {
+		c.Error("mobile is required", 400)
+		return
+	}
+	if strings.TrimSpace(password) == "" {
+		c.Error("password is required", 400)
+		return
+	}
+
+	svc := &services.SaasUserService{}
+	result, err := svc.Login(mobile, password)
+	if err != nil {
+		c.Error(err.Error(), 400)
+		return
+	}
+
+	c.Success(result, "login success")
+}

+ 15 - 0
controllers/default.go

@@ -0,0 +1,15 @@
+package controllers
+
+import (
+	beego "github.com/beego/beego/v2/server/web"
+)
+
+type MainController struct {
+	beego.Controller
+}
+
+func (c *MainController) Get() {
+	c.Data["Website"] = "beego.vip"
+	c.Data["Email"] = "astaxie@gmail.com"
+	c.TplName = "index.tpl"
+}

+ 272 - 0
controllers/services/KlineService.go

@@ -0,0 +1,272 @@
+package services
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"math"
+	"net"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	beego "github.com/beego/beego/v2/server/web"
+)
+
+type KlineItem struct {
+	OpenTime         int64  `json:"open_time"`
+	Open             string `json:"open"`
+	High             string `json:"high"`
+	Low              string `json:"low"`
+	Close            string `json:"close"`
+	Volume           string `json:"volume"`
+	CloseTime        int64  `json:"close_time"`
+	QuoteAssetVolume string `json:"quote_asset_volume"`
+	NumberOfTrades   int64  `json:"number_of_trades"`
+	TakerBuyBase     string `json:"taker_buy_base"`
+	TakerBuyQuote    string `json:"taker_buy_quote"`
+}
+
+type KlineService struct{}
+
+func getIntConfig(key string, fallback int) int {
+	raw, err := beego.AppConfig.String(key)
+	if err != nil || strings.TrimSpace(raw) == "" {
+		return fallback
+	}
+	v, err := strconv.Atoi(raw)
+	if err != nil || v <= 0 {
+		return fallback
+	}
+	return v
+}
+
+func getStringConfig(key, fallback string) string {
+	v, err := beego.AppConfig.String(key)
+	if err != nil || strings.TrimSpace(v) == "" {
+		return fallback
+	}
+	return strings.TrimSpace(v)
+}
+
+func getBaseURLs() []string {
+	// comma separated base urls, example:
+	// https://api.binance.com,https://api1.binance.com,https://api2.binance.com
+	raw := getStringConfig(
+		"kline_api_base_urls",
+		"https://api.binance.com,https://api1.binance.com,https://api2.binance.com,https://api3.binance.com",
+	)
+	parts := strings.Split(raw, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		p = strings.TrimSpace(strings.TrimRight(p, "/"))
+		if p != "" {
+			out = append(out, p)
+		}
+	}
+	if len(out) == 0 {
+		return []string{"https://api.binance.com"}
+	}
+	return out
+}
+
+func getBoolConfig(key string, fallback bool) bool {
+	raw, err := beego.AppConfig.String(key)
+	if err != nil || strings.TrimSpace(raw) == "" {
+		return fallback
+	}
+	v, err := strconv.ParseBool(strings.TrimSpace(raw))
+	if err != nil {
+		return fallback
+	}
+	return v
+}
+
+func newKlineHTTPClient(timeoutMS int) *http.Client {
+	dialer := &net.Dialer{
+		Timeout:   time.Duration(timeoutMS) * time.Millisecond,
+		KeepAlive: 30 * time.Second,
+	}
+
+	useSystemProxy := getBoolConfig("kline_use_system_proxy", false)
+	var proxyFn func(*http.Request) (*url.URL, error)
+	if useSystemProxy {
+		proxyFn = http.ProxyFromEnvironment
+	}
+
+	transport := &http.Transport{
+		Proxy: proxyFn,
+		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+			return dialer.DialContext(ctx, "tcp4", addr)
+		},
+		TLSHandshakeTimeout: time.Duration(timeoutMS) * time.Millisecond,
+		IdleConnTimeout:     60 * time.Second,
+	}
+	return &http.Client{
+		Timeout:   time.Duration(timeoutMS) * time.Millisecond,
+		Transport: transport,
+	}
+}
+
+func normalizeKlineInterval(interval string) (string, error) {
+	s := strings.TrimSpace(strings.ToLower(interval))
+	switch s {
+	case "hour", "h", "1h", "hourly":
+		return "1h", nil
+	case "day", "d", "1d", "daily":
+		return "1d", nil
+	case "week", "w", "1w", "weekly":
+		return "1w", nil
+	case "month", "m", "1m", "1mo", "1mon", "1month", "monthly":
+		return "1M", nil
+	default:
+		return "", errors.New("interval must be one of: hour/day/week/month")
+	}
+}
+
+func asString(v interface{}) string {
+	s, ok := v.(string)
+	if ok {
+		return s
+	}
+	return fmt.Sprint(v)
+}
+
+func asInt64(v interface{}) int64 {
+	switch vv := v.(type) {
+	case int64:
+		return vv
+	case int:
+		return int64(vv)
+	case float64:
+		// json.Unmarshal decodes numbers as float64 by default.
+		return int64(math.Round(vv))
+	case json.Number:
+		n, _ := vv.Int64()
+		return n
+	case string:
+		n, _ := strconv.ParseInt(strings.TrimSpace(vv), 10, 64)
+		return n
+	default:
+		s := strings.TrimSpace(fmt.Sprint(v))
+		if strings.ContainsAny(s, ".eE") {
+			f, _ := strconv.ParseFloat(s, 64)
+			return int64(math.Round(f))
+		}
+		n, _ := strconv.ParseInt(s, 10, 64)
+		return n
+	}
+}
+
+func (s *KlineService) FetchKlines(symbol, interval string, limit int, startTime, endTime int64) ([]KlineItem, error) {
+	if strings.TrimSpace(symbol) == "" {
+		symbol = "BTCUSDT"
+	}
+	symbol = strings.ToUpper(strings.TrimSpace(symbol))
+
+	apiInterval, err := normalizeKlineInterval(interval)
+	if err != nil {
+		return nil, err
+	}
+
+	if limit <= 0 {
+		limit = 200
+	}
+	if limit > 1000 {
+		limit = 1000
+	}
+
+	timeoutMS := getIntConfig("kline_http_timeout_ms", 12000)
+	retries := getIntConfig("kline_http_retries", 2)
+	if retries < 1 {
+		retries = 1
+	}
+
+	client := newKlineHTTPClient(timeoutMS)
+	baseURLs := getBaseURLs()
+	var body []byte
+	var lastErr error
+
+	for _, base := range baseURLs {
+		for i := 0; i < retries; i++ {
+			u, _ := url.Parse(base + "/api/v3/klines")
+			q := u.Query()
+			q.Set("symbol", symbol)
+			q.Set("interval", apiInterval)
+			q.Set("limit", strconv.Itoa(limit))
+			if startTime > 0 {
+				q.Set("startTime", strconv.FormatInt(startTime, 10))
+			}
+			if endTime > 0 {
+				q.Set("endTime", strconv.FormatInt(endTime, 10))
+			}
+			u.RawQuery = q.Encode()
+
+			req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+			if err != nil {
+				lastErr = err
+				continue
+			}
+			req.Header.Set("Accept", "application/json")
+			req.Header.Set("User-Agent", "think-go-kline/1.0")
+
+			resp, err := client.Do(req)
+			if err != nil {
+				lastErr = fmt.Errorf("request %s failed: %w", base, err)
+				continue
+			}
+
+			body, err = io.ReadAll(resp.Body)
+			resp.Body.Close()
+			if err != nil {
+				lastErr = fmt.Errorf("read %s response failed: %w", base, err)
+				continue
+			}
+			if resp.StatusCode != http.StatusOK {
+				lastErr = fmt.Errorf("kline api %s error: status=%d body=%s", base, resp.StatusCode, string(body))
+				continue
+			}
+
+			lastErr = nil
+			break
+		}
+		if lastErr == nil {
+			break
+		}
+	}
+
+	if lastErr != nil {
+		return nil, fmt.Errorf("all kline endpoints failed: %w", lastErr)
+	}
+
+	var raw [][]interface{}
+	if err := json.Unmarshal(body, &raw); err != nil {
+		return nil, err
+	}
+
+	out := make([]KlineItem, 0, len(raw))
+	for _, row := range raw {
+		if len(row) < 11 {
+			continue
+		}
+		out = append(out, KlineItem{
+			OpenTime:         asInt64(row[0]),
+			Open:             asString(row[1]),
+			High:             asString(row[2]),
+			Low:              asString(row[3]),
+			Close:            asString(row[4]),
+			Volume:           asString(row[5]),
+			CloseTime:        asInt64(row[6]),
+			QuoteAssetVolume: asString(row[7]),
+			NumberOfTrades:   asInt64(row[8]),
+			TakerBuyBase:     asString(row[9]),
+			TakerBuyQuote:    asString(row[10]),
+		})
+	}
+
+	return out, nil
+}

+ 67 - 0
controllers/services/SaasUserService.go

@@ -0,0 +1,67 @@
+package services
+
+import (
+	"errors"
+	"think-go/models"
+	"time"
+
+	"github.com/beego/beego/v2/client/orm"
+	beego "github.com/beego/beego/v2/server/web"
+	"github.com/dgrijalva/jwt-go"
+)
+
+type SaasUserService struct{}
+
+type LoginClaims struct {
+	UserID int    `json:"user_id"`
+	Mobile string `json:"mobile"`
+	jwt.StandardClaims
+}
+
+type LoginResult struct {
+	//User      *models.CyySaasUser `json:"user"`
+	Token     string `json:"token"`
+	ExpiresAt int64  `json:"expires_at"`
+}
+
+func (s *SaasUserService) Login(mobile, password string) (*LoginResult, error) {
+	o := orm.NewOrm()
+	user := &models.CyySaasUser{Mobile: mobile}
+
+	err := o.Read(user, "Mobile")
+	if err == orm.ErrNoRows {
+		return nil, errors.New("user not found")
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: add real password verification here.
+	_ = password
+
+	expireAt := time.Now().Add(24 * time.Hour).Unix()
+	secret, _ := beego.AppConfig.String("jwt_secret")
+	if secret == "" {
+		secret = "your-secret-key"
+	}
+
+	claims := LoginClaims{
+		UserID: user.Id,
+		Mobile: user.Mobile,
+		StandardClaims: jwt.StandardClaims{
+			ExpiresAt: expireAt,
+			IssuedAt:  time.Now().Unix(),
+		},
+	}
+
+	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
+	if err != nil {
+		return nil, err
+	}
+
+	return &LoginResult{
+		//User:      user,
+		Token:     token,
+		ExpiresAt: expireAt,
+	}, nil
+}

+ 39 - 0
go.mod

@@ -0,0 +1,39 @@
+module think-go
+
+go 1.26
+
+require github.com/beego/beego/v2 v2.1.0
+
+require (
+	github.com/dgrijalva/jwt-go v3.2.0+incompatible
+	github.com/go-sql-driver/mysql v1.9.3
+	github.com/smartystreets/goconvey v1.6.4
+)
+
+require (
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+	github.com/go-redis/redis/v8 v8.11.5 // indirect
+	github.com/golang/protobuf v1.5.3 // indirect
+	github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
+	github.com/hashicorp/golang-lru v0.5.4 // indirect
+	github.com/jtolds/gls v4.20.0+incompatible // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
+	github.com/mitchellh/mapstructure v1.5.0 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/prometheus/client_golang v1.15.1 // indirect
+	github.com/prometheus/client_model v0.3.0 // indirect
+	github.com/prometheus/common v0.42.0 // indirect
+	github.com/prometheus/procfs v0.9.0 // indirect
+	github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect
+	github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
+	golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd // indirect
+	golang.org/x/net v0.7.0 // indirect
+	golang.org/x/sys v0.6.0 // indirect
+	golang.org/x/text v0.7.0 // indirect
+	google.golang.org/protobuf v1.30.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)

+ 99 - 0
go.sum

@@ -0,0 +1,99 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/beego/beego/v2 v2.1.0 h1:Lk0FtQGvDQCx5V5yEu4XwDsIgt+QOlNjt5emUa3/ZmA=
+github.com/beego/beego/v2 v2.1.0/go.mod h1:6h36ISpaxNrrpJ27siTpXBG8d/Icjzsc7pU1bWpp0EE=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
+github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
+github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
+github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
+github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
+github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
+github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
+github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
+github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
+github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
+github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
+github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
+github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
+github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
+github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
+github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik=
+github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd h1:XcWmESyNjXJMLahc3mqVQJcgSTDxFxhETVlfk9uGc38=
+golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 109 - 0
main.go

@@ -0,0 +1,109 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"strconv"
+	_ "think-go/models"
+	_ "think-go/routers"
+	"think-go/utils"
+	"time"
+
+	"github.com/beego/beego/v2/client/orm"
+	beego "github.com/beego/beego/v2/server/web"
+	_ "github.com/go-sql-driver/mysql"
+)
+
+const (
+	defaultMaxIdleConns = 10
+	defaultMaxOpenConns = 100
+	defaultConnMaxLife  = 300
+)
+
+func getStringConfig(key string, fallback string) string {
+	v, err := beego.AppConfig.String(key)
+	if err != nil || v == "" {
+		return fallback
+	}
+	return v
+}
+
+func getIntConfig(key string, fallback int) int {
+	raw, err := beego.AppConfig.String(key)
+	if err != nil || raw == "" {
+		return fallback
+	}
+
+	v, err := strconv.Atoi(raw)
+	if err != nil || v <= 0 {
+		log.Printf("invalid config %s=%q, fallback=%d", key, raw, fallback)
+		return fallback
+	}
+
+	return v
+}
+
+func buildMySQLDSN() string {
+	user := getStringConfig("db_user", "root")
+	password := getStringConfig("db_password", "root")
+	host := getStringConfig("db_host", "127.0.0.1")
+	port := getStringConfig("db_port", "3306")
+	name := getStringConfig("db_name", "gva")
+	charset := getStringConfig("db_charset", "utf8mb4")
+	parseTime := getStringConfig("db_parse_time", "True")
+	loc := getStringConfig("db_loc", "Local")
+
+	return fmt.Sprintf(
+		"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%s&loc=%s",
+		user,
+		password,
+		host,
+		port,
+		name,
+		charset,
+		parseTime,
+		loc,
+	)
+}
+
+func init() {
+	if err := orm.RegisterDriver("mysql", orm.DRMySQL); err != nil {
+		log.Fatalf("register mysql driver failed: %v", err)
+	}
+
+	dbConn := getStringConfig("sqlconn", "")
+	if dbConn == "" {
+		dbConn = buildMySQLDSN()
+	}
+
+	maxIdle := getIntConfig("db_max_idle_conns", defaultMaxIdleConns)
+	maxOpen := getIntConfig("db_max_open_conns", defaultMaxOpenConns)
+	connLife := getIntConfig("db_conn_max_lifetime_sec", defaultConnMaxLife)
+
+	if err := orm.RegisterDataBase(
+		"default",
+		"mysql",
+		dbConn,
+		orm.MaxIdleConnections(maxIdle),
+		orm.MaxOpenConnections(maxOpen),
+		orm.ConnMaxLifetime(time.Duration(connLife)*time.Second),
+	); err != nil {
+		log.Fatalf("register database failed: %v", err)
+	}
+
+	if runmode, _ := beego.AppConfig.String("runmode"); runmode == "dev" {
+		orm.Debug = true
+	}
+
+	if err := utils.InitRedis(); err != nil {
+		log.Fatalf("init redis failed: %v", err)
+	}
+	if err := utils.InitWxPay(); err != nil {
+		log.Fatalf("init wxpay failed: %v", err)
+	}
+}
+
+func main() {
+	defer utils.CloseRedis()
+	beego.Run()
+}

+ 194 - 0
models/cyy_saas_user.go

@@ -0,0 +1,194 @@
+package models
+
+import (
+	"errors"
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/beego/beego/v2/client/orm"
+)
+
+type CyySaasUser struct {
+	Id                          int     `orm:"column(id);auto"`
+	Uid                         int     `orm:"column(uid);null" description:"用户uid"`
+	Mobile                      string  `orm:"column(mobile);size(11)" description:"手机号"`
+	ParentId                    int     `orm:"column(parent_id);null" description:"上级id"`
+	CreatedAt                   int     `orm:"column(created_at);null" description:"创建时间"`
+	UpdatedAt                   int     `orm:"column(updated_at);null" description:"更新时间"`
+	IsDelete                    int8    `orm:"column(is_delete);null" description:"是否删除"`
+	Price                       float64 `orm:"column(price);null;digits(10);decimals(2)" description:"可提现佣金"`
+	TotalPrice                  float64 `orm:"column(total_price);null;digits(10);decimals(2)" description:"累计佣金"`
+	StoreId                     int     `orm:"column(store_id)" description:"商城ID,表示该用户是哪个商城带来的"`
+	PlatformOpenId              string  `orm:"column(platform_open_id);size(255)" description:"平台openid"`
+	PlatformOpenIdMerchant      string  `orm:"column(platform_open_id_merchant);size(255);null" description:"批发端openid"`
+	ShareProfit                 float64 `orm:"column(share_profit);digits(10);decimals(2)" description:"联盟佣金"`
+	Name                        string  `orm:"column(name);size(255)" description:"昵称"`
+	Gender                      int8    `orm:"column(gender);null" description:"性别,0: 未设置,1:男,2:女"`
+	Avatar                      string  `orm:"column(avatar);size(255)" description:"头像"`
+	AccessToken                 string  `orm:"column(access_token);size(255)" description:"token"`
+	TotalIntegral               int     `orm:"column(total_integral);null"`
+	Integral                    float64 `orm:"column(integral);digits(10);decimals(2)" description:"积分"`
+	BytedanceOpenId             string  `orm:"column(bytedance_open_id);size(255)" description:"bytedance open id"`
+	WithdrawMethod              string  `orm:"column(withdraw_method);null" description:"提现方式"`
+	IsSalesman                  int8    `orm:"column(is_salesman)" description:"是否是业务员"`
+	AliUserId                   string  `orm:"column(ali_user_id);size(50)" description:"支付宝用户ID 18位"`
+	LeaguePrice                 float64 `orm:"column(league_price);null;digits(10);decimals(2)"`
+	SaasIntegral                int     `orm:"column(saas_integral);null" description:"联盟用户积分"`
+	Money                       float64 `orm:"column(money);null;digits(10);decimals(2)" description:"余额"`
+	CircleBg                    string  `orm:"column(circle_bg);size(255);null" description:"朋友圈背景图"`
+	AliOpenId                   string  `orm:"column(ali_openId);size(255);null" description:"支付宝openid"`
+	CloudInventoryBalance       float64 `orm:"column(cloud_inventory_balance);null;digits(10);decimals(2)" description:"可提现云库存余额"`
+	CloudInventoryFreezeBalance float64 `orm:"column(cloud_inventory_freeze_balance);null;digits(10);decimals(2)" description:"冻结云库存余额"`
+	CloudInventoryTotalBalance  float64 `orm:"column(cloud_inventory_total_balance);null;digits(10);decimals(2)" description:"累计云库存余额"`
+	PurchaseMoney               float64 `orm:"column(purchase_money);null;digits(10);decimals(2)" description:"采购金"`
+	IsCloudInventory            int8    `orm:"column(is_cloud_inventory);null" description:"是否云库存用户"`
+	CloudInventoryLevel         int8    `orm:"column(cloud_inventory_level);null" description:"云库存会员等级"`
+	CanOpenStore                int     `orm:"column(can_open_store)" description:"是否有开店权限"`
+	AiCloudInventory            int8    `orm:"column(ai_cloud_inventory);null" description:"开启AI云库存 0关闭 1开启"`
+	AutoExchange                int8    `orm:"column(auto_exchange);null" description:"自动兑换采购金"`
+	AiShopping                  int8    `orm:"column(ai_shopping);null" description:"AI带货"`
+	PlatformOpenIdNew           string  `orm:"column(platform_open_id_new);size(255);null" description:"串码联名openid"`
+	WechatUnionId               string  `orm:"column(wechat_union_id);size(255);null"`
+	SaasUnionId                 string  `orm:"column(saas_union_id);size(255);null"`
+	IsPublicSphere              int8    `orm:"column(is_public_sphere);null" description:"是否公域用户"`
+	TotalAmount                 float64 `orm:"column(total_amount);null;digits(10);decimals(6)" description:"可用倍数"`
+	WaitAmount                  float64 `orm:"column(wait_amount);null;digits(10);decimals(6)" description:"待领取红包"`
+	LeijiTotalAmount            float64 `orm:"column(leiji_total_amount);null;digits(10);decimals(6)" description:"累计倍数"`
+	UnionId                     string  `orm:"column(union_id);size(255);null"`
+}
+
+func (t *CyySaasUser) TableName() string {
+	return "cyy_saas_user"
+}
+
+func init() {
+	orm.RegisterModel(new(CyySaasUser))
+}
+
+// AddCyySaasUser insert a new CyySaasUser into database and returns
+// last inserted Id on success.
+func AddCyySaasUser(m *CyySaasUser) (id int64, err error) {
+	o := orm.NewOrm()
+	id, err = o.Insert(m)
+	return
+}
+
+// GetCyySaasUserById retrieves CyySaasUser by Id. Returns error if
+// Id doesn't exist
+func GetCyySaasUserById(id int) (v *CyySaasUser, err error) {
+	o := orm.NewOrm()
+	v = &CyySaasUser{Id: id}
+	if err = o.Read(v); err == nil {
+		return v, nil
+	}
+	return nil, err
+}
+
+// GetAllCyySaasUser retrieves all CyySaasUser matches certain condition. Returns empty list if
+// no records exist
+func GetAllCyySaasUser(query map[string]string, fields []string, sortby []string, order []string,
+	offset int64, limit int64) (ml []interface{}, err error) {
+	o := orm.NewOrm()
+	qs := o.QueryTable(new(CyySaasUser))
+	// query k=v
+	for k, v := range query {
+		// rewrite dot-notation to Object__Attribute
+		k = strings.Replace(k, ".", "__", -1)
+		if strings.Contains(k, "isnull") {
+			qs = qs.Filter(k, (v == "true" || v == "1"))
+		} else {
+			qs = qs.Filter(k, v)
+		}
+	}
+	// order by:
+	var sortFields []string
+	if len(sortby) != 0 {
+		if len(sortby) == len(order) {
+			// 1) for each sort field, there is an associated order
+			for i, v := range sortby {
+				orderby := ""
+				if order[i] == "desc" {
+					orderby = "-" + v
+				} else if order[i] == "asc" {
+					orderby = v
+				} else {
+					return nil, errors.New("Error: Invalid order. Must be either [asc|desc]")
+				}
+				sortFields = append(sortFields, orderby)
+			}
+			qs = qs.OrderBy(sortFields...)
+		} else if len(sortby) != len(order) && len(order) == 1 {
+			// 2) there is exactly one order, all the sorted fields will be sorted by this order
+			for _, v := range sortby {
+				orderby := ""
+				if order[0] == "desc" {
+					orderby = "-" + v
+				} else if order[0] == "asc" {
+					orderby = v
+				} else {
+					return nil, errors.New("Error: Invalid order. Must be either [asc|desc]")
+				}
+				sortFields = append(sortFields, orderby)
+			}
+		} else if len(sortby) != len(order) && len(order) != 1 {
+			return nil, errors.New("Error: 'sortby', 'order' sizes mismatch or 'order' size is not 1")
+		}
+	} else {
+		if len(order) != 0 {
+			return nil, errors.New("Error: unused 'order' fields")
+		}
+	}
+
+	var l []CyySaasUser
+	qs = qs.OrderBy(sortFields...)
+	if _, err = qs.Limit(limit, offset).All(&l, fields...); err == nil {
+		if len(fields) == 0 {
+			for _, v := range l {
+				ml = append(ml, v)
+			}
+		} else {
+			// trim unused fields
+			for _, v := range l {
+				m := make(map[string]interface{})
+				val := reflect.ValueOf(v)
+				for _, fname := range fields {
+					m[fname] = val.FieldByName(fname).Interface()
+				}
+				ml = append(ml, m)
+			}
+		}
+		return ml, nil
+	}
+	return nil, err
+}
+
+// UpdateCyySaasUser updates CyySaasUser by Id and returns error if
+// the record to be updated doesn't exist
+func UpdateCyySaasUserById(m *CyySaasUser) (err error) {
+	o := orm.NewOrm()
+	v := CyySaasUser{Id: m.Id}
+	// ascertain id exists in the database
+	if err = o.Read(&v); err == nil {
+		var num int64
+		if num, err = o.Update(m); err == nil {
+			fmt.Println("Number of records updated in database:", num)
+		}
+	}
+	return
+}
+
+// DeleteCyySaasUser deletes CyySaasUser by Id and returns error if
+// the record to be deleted doesn't exist
+func DeleteCyySaasUser(id int) (err error) {
+	o := orm.NewOrm()
+	v := CyySaasUser{Id: id}
+	// ascertain id exists in the database
+	if err = o.Read(&v); err == nil {
+		var num int64
+		if num, err = o.Delete(&CyySaasUser{Id: id}); err == nil {
+			fmt.Println("Number of records deleted in database:", num)
+		}
+	}
+	return
+}

+ 22 - 0
routers/router.go

@@ -0,0 +1,22 @@
+package routers
+
+import (
+	"think-go/controllers"
+	"think-go/controllers/admin"
+
+	beego "github.com/beego/beego/v2/server/web"
+)
+
+func init() {
+	beego.Router("/", &controllers.MainController{})
+
+	ns := beego.NewNamespace("/admin",
+		beego.NSRouter("/login/login", &admin.LoginController{}, "get,post:Login"),
+		beego.NSRouter("/user/add", &admin.UserController{}, "get,post:Add"),
+		beego.NSRouter("/user/:id", &admin.UserController{}, "get:GetUser"),
+		beego.NSRouter("/game/tetris", &admin.GameController{}, "get:Tetris"),
+		beego.NSRouter("/coin/kline", &admin.CoinController{}, "get:Kline"),
+		beego.NSRouter("/coin/kline/view", &admin.CoinController{}, "get:KlineView"),
+	)
+	beego.AddNamespace(ns)
+}

+ 1 - 0
static/js/reload.min.js

@@ -0,0 +1 @@
+function b(a){var c=new WebSocket(a);c.onclose=function(){setTimeout(function(){b(a)},2E3)};c.onmessage=function(){location.reload()}}try{if(window.WebSocket)try{b("ws://localhost:12450/reload")}catch(a){console.error(a)}else console.log("Your browser does not support WebSockets.")}catch(a){console.error("Exception during connecting to Reload:",a)};

+ 42 - 0
tests/default_test.go

@@ -0,0 +1,42 @@
+package test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"runtime"
+	"path/filepath"
+
+    "github.com/beego/beego/v2/core/logs"
+
+	_ "think-go/routers"
+
+	beego "github.com/beego/beego/v2/server/web"
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func init() {
+	_, file, _, _ := runtime.Caller(0)
+	apppath, _ := filepath.Abs(filepath.Dir(filepath.Join(file, ".." + string(filepath.Separator))))
+	beego.TestBeegoInit(apppath)
+}
+
+
+// TestBeego is a sample to run an endpoint test
+func TestBeego(t *testing.T) {
+	r, _ := http.NewRequest("GET", "/", nil)
+	w := httptest.NewRecorder()
+	beego.BeeApp.Handlers.ServeHTTP(w, r)
+
+	logs.Trace("testing", "TestBeego", "Code[%d]\n%s", w.Code, w.Body.String())
+
+	Convey("Subject: Test Station Endpoint\n", t, func() {
+	        Convey("Status Code Should Be 200", func() {
+	                So(w.Code, ShouldEqual, 200)
+	        })
+	        Convey("The Result Should Not Be Empty", func() {
+	                So(w.Body.Len(), ShouldBeGreaterThan, 0)
+	        })
+	})
+}
+

BIN
tmp/main.exe


+ 95 - 0
utils/redis.go

@@ -0,0 +1,95 @@
+package utils
+
+import (
+	"context"
+	"log"
+	"strconv"
+	"time"
+
+	beego "github.com/beego/beego/v2/server/web"
+	"github.com/go-redis/redis/v8"
+)
+
+var RedisClient *redis.Client
+
+func redisString(key string, fallback string) string {
+	v, err := beego.AppConfig.String(key)
+	if err != nil || v == "" {
+		return fallback
+	}
+	return v
+}
+
+func redisInt(key string, fallback int) int {
+	raw, err := beego.AppConfig.String(key)
+	if err != nil || raw == "" {
+		return fallback
+	}
+
+	v, err := strconv.Atoi(raw)
+	if err != nil || v < 0 {
+		log.Printf("invalid redis config %s=%q, fallback=%d", key, raw, fallback)
+		return fallback
+	}
+	return v
+}
+
+func redisBool(key string, fallback bool) bool {
+	raw, err := beego.AppConfig.String(key)
+	if err != nil || raw == "" {
+		return fallback
+	}
+
+	v, err := strconv.ParseBool(raw)
+	if err != nil {
+		log.Printf("invalid redis config %s=%q, fallback=%t", key, raw, fallback)
+		return fallback
+	}
+	return v
+}
+
+func InitRedis() error {
+	if !redisBool("redis_enable", false) {
+		return nil
+	}
+
+	addr := redisString("redis_addr", "127.0.0.1:6379")
+	password := redisString("redis_password", "")
+	db := redisInt("redis_db", 0)
+	poolSize := redisInt("redis_pool_size", 10)
+	minIdleConns := redisInt("redis_min_idle_conns", 2)
+	dialTimeoutMS := redisInt("redis_dial_timeout_ms", 5000)
+	readTimeoutMS := redisInt("redis_read_timeout_ms", 3000)
+	writeTimeoutMS := redisInt("redis_write_timeout_ms", 3000)
+	pingTimeoutMS := redisInt("redis_ping_timeout_ms", 3000)
+
+	client := redis.NewClient(&redis.Options{
+		Addr:         addr,
+		Password:     password,
+		DB:           db,
+		PoolSize:     poolSize,
+		MinIdleConns: minIdleConns,
+		DialTimeout:  time.Duration(dialTimeoutMS) * time.Millisecond,
+		ReadTimeout:  time.Duration(readTimeoutMS) * time.Millisecond,
+		WriteTimeout: time.Duration(writeTimeoutMS) * time.Millisecond,
+	})
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(pingTimeoutMS)*time.Millisecond)
+	defer cancel()
+
+	if err := client.Ping(ctx).Err(); err != nil {
+		_ = client.Close()
+		return err
+	}
+
+	RedisClient = client
+	log.Printf("redis connected: addr=%s db=%d", addr, db)
+	return nil
+}
+
+func CloseRedis() {
+	if RedisClient == nil {
+		return
+	}
+	_ = RedisClient.Close()
+}

+ 56 - 0
utils/redis_lock.go

@@ -0,0 +1,56 @@
+package utils
+
+import (
+	"context"
+	"errors"
+	"time"
+)
+
+var ErrRedisNotInitialized = errors.New("redis client is not initialized, set redis_enable=true and restart")
+
+const redisLockReleaseScript = `
+if redis.call("GET", KEYS[1]) == ARGV[1] then
+	return redis.call("DEL", KEYS[1])
+else
+	return 0
+end
+`
+
+// AcquireRedisLock tries to create a distributed lock with key and value.
+// It returns true when lock is acquired, false when lock already exists.
+func AcquireRedisLock(key, value string) (bool, error) {
+	if RedisClient == nil {
+		return false, ErrRedisNotInitialized
+	}
+	if key == "" || value == "" {
+		return false, errors.New("key and value must not be empty")
+	}
+
+	lockTTLMS := redisInt("redis_lock_ttl_ms", 30000)
+	opTimeoutMS := redisInt("redis_lock_op_timeout_ms", 1000)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(opTimeoutMS)*time.Millisecond)
+	defer cancel()
+
+	return RedisClient.SetNX(ctx, key, value, time.Duration(lockTTLMS)*time.Millisecond).Result()
+}
+
+// ReleaseRedisLock releases lock only when key currently matches value.
+// It returns true when released successfully.
+func ReleaseRedisLock(key, value string) (bool, error) {
+	if RedisClient == nil {
+		return false, ErrRedisNotInitialized
+	}
+	if key == "" || value == "" {
+		return false, errors.New("key and value must not be empty")
+	}
+
+	opTimeoutMS := redisInt("redis_lock_op_timeout_ms", 1000)
+	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(opTimeoutMS)*time.Millisecond)
+	defer cancel()
+
+	n, err := RedisClient.Eval(ctx, redisLockReleaseScript, []string{key}, value).Int64()
+	if err != nil {
+		return false, err
+	}
+	return n == 1, nil
+}

+ 87 - 0
utils/request.go

@@ -0,0 +1,87 @@
+package utils
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/beego/beego/v2/server/web"
+)
+
+const jsonBodyCacheKey = "__request_json_body_map"
+
+func parseRequestJSONBody(c *web.Controller) (map[string]interface{}, error) {
+	if cached := c.Ctx.Input.GetData(jsonBodyCacheKey); cached != nil {
+		if m, ok := cached.(map[string]interface{}); ok {
+			return m, nil
+		}
+	}
+
+	contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type"))
+
+	body := c.Ctx.Input.RequestBody
+	if len(body) == 0 {
+		if c.Ctx.Request != nil && c.Ctx.Request.Body != nil {
+			readBody, err := io.ReadAll(c.Ctx.Request.Body)
+			if err != nil {
+				return nil, err
+			}
+			body = readBody
+			c.Ctx.Input.RequestBody = readBody
+			c.Ctx.Request.Body = io.NopCloser(bytes.NewBuffer(readBody))
+		}
+	}
+	if len(body) == 0 {
+		return nil, nil
+	}
+
+	trimBody := strings.TrimSpace(string(body))
+	if trimBody == "" {
+		return nil, nil
+	}
+	if !strings.HasPrefix(trimBody, "{") && !strings.HasPrefix(trimBody, "[") {
+		return nil, nil
+	}
+
+	var m map[string]interface{}
+	if err := json.Unmarshal(body, &m); err != nil {
+		if strings.Contains(contentType, "application/json") {
+			return nil, err
+		}
+		return nil, nil
+	}
+
+	c.Ctx.Input.SetData(jsonBodyCacheKey, m)
+	return m, nil
+}
+
+// GetRequestString gets parameter by key.
+// It tries form/query first, then falls back to JSON body for application/json.
+func GetRequestString(c *web.Controller, key string) (string, error) {
+	v := strings.TrimSpace(c.GetString(key))
+	if v != "" {
+		return v, nil
+	}
+
+	m, err := parseRequestJSONBody(c)
+	if err != nil {
+		return "", err
+	}
+	if m == nil {
+		return "", nil
+	}
+
+	raw, ok := m[key]
+	if !ok || raw == nil {
+		return "", nil
+	}
+
+	switch vv := raw.(type) {
+	case string:
+		return strings.TrimSpace(vv), nil
+	default:
+		return strings.TrimSpace(fmt.Sprint(vv)), nil
+	}
+}

+ 23 - 0
utils/response.go

@@ -0,0 +1,23 @@
+package utils
+
+import (
+	"github.com/beego/beego/v2/server/web"
+)
+
+// Response 统一 JSON 结构体
+type Response struct {
+	Code int         `json:"code"`
+	Msg  string      `json:"msg"`
+	Data interface{} `json:"data"`
+}
+
+// JSON 统一返回处理
+func JSON(c *web.Controller, code int, msg string, data interface{}) {
+	c.Data["json"] = Response{
+		Code: code,
+		Msg:  msg,
+		Data: data,
+	}
+	c.ServeJSON()
+	c.StopRun() // 类似 PHP 的 exit()
+}

+ 475 - 0
utils/wechat_pay.go

@@ -0,0 +1,475 @@
+package utils
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type WxPayClient struct {
+	MchID      string
+	SerialNo   string
+	NotifyURL  string
+	JSAPIAppID string
+	H5AppID    string
+	AppAppID   string
+	PrivateKey *rsa.PrivateKey
+	HTTPClient *http.Client
+	APIBase    string
+	Enabled    bool
+	UserAgent  string
+}
+
+type WxPayAmount struct {
+	Total    int    `json:"total"`
+	Currency string `json:"currency,omitempty"`
+}
+
+type WxPayPayer struct {
+	OpenID string `json:"openid,omitempty"`
+}
+
+type WxPayH5Info struct {
+	Type string `json:"type"`
+}
+
+type WxPaySceneInfo struct {
+	PayerClientIP string       `json:"payer_client_ip,omitempty"`
+	H5Info        *WxPayH5Info `json:"h5_info,omitempty"`
+}
+
+type wxPayTxnRequest struct {
+	AppID       string          `json:"appid"`
+	MchID       string          `json:"mchid"`
+	Description string          `json:"description"`
+	OutTradeNo  string          `json:"out_trade_no"`
+	NotifyURL   string          `json:"notify_url"`
+	Attach      string          `json:"attach,omitempty"`
+	Amount      WxPayAmount     `json:"amount"`
+	Payer       *WxPayPayer     `json:"payer,omitempty"`
+	SceneInfo   *WxPaySceneInfo `json:"scene_info,omitempty"`
+}
+
+type wxPayTxnResp struct {
+	PrepayID string `json:"prepay_id"`
+	H5URL    string `json:"h5_url"`
+}
+
+type WxMiniProgramOrderReq struct {
+	Description string
+	OutTradeNo  string
+	Total       int
+	Currency    string
+	OpenID      string
+	NotifyURL   string
+	Attach      string
+	AppID       string
+}
+
+type WxMiniProgramPayResult struct {
+	AppID     string `json:"appId"`
+	TimeStamp string `json:"timeStamp"`
+	NonceStr  string `json:"nonceStr"`
+	Package   string `json:"package"`
+	SignType  string `json:"signType"`
+	PaySign   string `json:"paySign"`
+	PrepayID  string `json:"prepayId"`
+}
+
+type WxH5OrderReq struct {
+	Description   string
+	OutTradeNo    string
+	Total         int
+	Currency      string
+	NotifyURL     string
+	Attach        string
+	PayerClientIP string
+	AppID         string
+}
+
+type WxH5PayResult struct {
+	H5URL string `json:"h5Url"`
+}
+
+type WxAppOrderReq struct {
+	Description string
+	OutTradeNo  string
+	Total       int
+	Currency    string
+	NotifyURL   string
+	Attach      string
+	AppID       string
+}
+
+type WxAppPayResult struct {
+	AppID     string `json:"appid"`
+	PartnerID string `json:"partnerid"`
+	PrepayID  string `json:"prepayid"`
+	Package   string `json:"package"`
+	NonceStr  string `json:"noncestr"`
+	TimeStamp string `json:"timestamp"`
+	Sign      string `json:"sign"`
+}
+
+var wxPayClient *WxPayClient
+
+func InitWxPay() error {
+	enabled := redisBool("wxpay_enable", false)
+	if !enabled {
+		return nil
+	}
+
+	mchID := redisString("wxpay_mch_id", "")
+	serialNo := redisString("wxpay_serial_no", "")
+	privateKeyPath := redisString("wxpay_private_key_path", "")
+	notifyURL := redisString("wxpay_notify_url", "")
+	jsapiAppID := redisString("wxpay_jsapi_appid", "")
+	h5AppID := redisString("wxpay_h5_appid", "")
+	appAppID := redisString("wxpay_app_appid", "")
+	apiBase := redisString("wxpay_api_base", "https://api.mch.weixin.qq.com")
+
+	if mchID == "" || serialNo == "" || privateKeyPath == "" || notifyURL == "" {
+		return errors.New("wxpay config missing: wxpay_mch_id/wxpay_serial_no/wxpay_private_key_path/wxpay_notify_url are required")
+	}
+
+	privateKey, err := loadRSAPrivateKey(privateKeyPath)
+	if err != nil {
+		return err
+	}
+
+	wxPayClient = &WxPayClient{
+		MchID:      mchID,
+		SerialNo:   serialNo,
+		NotifyURL:  notifyURL,
+		JSAPIAppID: jsapiAppID,
+		H5AppID:    h5AppID,
+		AppAppID:   appAppID,
+		PrivateKey: privateKey,
+		HTTPClient: &http.Client{Timeout: 15 * time.Second},
+		APIBase:    strings.TrimRight(apiBase, "/"),
+		Enabled:    true,
+		UserAgent:  "think-go-wxpay/1.0",
+	}
+
+	return nil
+}
+
+func GetWxPayClient() (*WxPayClient, error) {
+	if wxPayClient == nil || !wxPayClient.Enabled {
+		return nil, errors.New("wxpay not initialized, set wxpay_enable=true and call InitWxPay")
+	}
+	return wxPayClient, nil
+}
+
+func CreateWxMiniProgramPay(req WxMiniProgramOrderReq) (*WxMiniProgramPayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateMiniProgramPay(req)
+}
+
+func CreateWxH5Pay(req WxH5OrderReq) (*WxH5PayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateH5Pay(req)
+}
+
+func CreateWxAppPay(req WxAppOrderReq) (*WxAppPayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateAppPay(req)
+}
+
+func (c *WxPayClient) CreateMiniProgramPay(req WxMiniProgramOrderReq) (*WxMiniProgramPayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.JSAPIAppID
+	}
+	if appID == "" {
+		return nil, errors.New("jsapi appid is required")
+	}
+	if req.OpenID == "" {
+		return nil, errors.New("openid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+		Payer: &WxPayPayer{OpenID: req.OpenID},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/jsapi", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.PrepayID == "" {
+		return nil, errors.New("wxpay jsapi response missing prepay_id")
+	}
+
+	timeStamp := strconv.FormatInt(time.Now().Unix(), 10)
+	nonceStr := randomString(32)
+	pkg := "prepay_id=" + resp.PrepayID
+	signPayload := appID + "\n" + timeStamp + "\n" + nonceStr + "\n" + pkg + "\n"
+	paySign, err := c.signBase64(signPayload)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WxMiniProgramPayResult{
+		AppID:     appID,
+		TimeStamp: timeStamp,
+		NonceStr:  nonceStr,
+		Package:   pkg,
+		SignType:  "RSA",
+		PaySign:   paySign,
+		PrepayID:  resp.PrepayID,
+	}, nil
+}
+
+func (c *WxPayClient) CreateH5Pay(req WxH5OrderReq) (*WxH5PayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.H5AppID
+	}
+	if appID == "" {
+		return nil, errors.New("h5 appid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+	if req.PayerClientIP == "" {
+		return nil, errors.New("payer_client_ip is required for h5 pay")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+		SceneInfo: &WxPaySceneInfo{
+			PayerClientIP: req.PayerClientIP,
+			H5Info:        &WxPayH5Info{Type: "Wap"},
+		},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/h5", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.H5URL == "" {
+		return nil, errors.New("wxpay h5 response missing h5_url")
+	}
+	return &WxH5PayResult{H5URL: resp.H5URL}, nil
+}
+
+func (c *WxPayClient) CreateAppPay(req WxAppOrderReq) (*WxAppPayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.AppAppID
+	}
+	if appID == "" {
+		return nil, errors.New("app appid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/app", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.PrepayID == "" {
+		return nil, errors.New("wxpay app response missing prepay_id")
+	}
+
+	timeStamp := strconv.FormatInt(time.Now().Unix(), 10)
+	nonceStr := randomString(32)
+	signPayload := appID + "\n" + timeStamp + "\n" + nonceStr + "\n" + resp.PrepayID + "\n"
+	sign, err := c.signBase64(signPayload)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WxAppPayResult{
+		AppID:     appID,
+		PartnerID: c.MchID,
+		PrepayID:  resp.PrepayID,
+		Package:   "Sign=WXPay",
+		NonceStr:  nonceStr,
+		TimeStamp: timeStamp,
+		Sign:      sign,
+	}, nil
+}
+
+func (c *WxPayClient) postJSON(path string, payload interface{}, out interface{}) error {
+	bodyBytes, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	fullURL := c.APIBase + path
+
+	auth, err := c.buildAuthorization("POST", fullURL, string(bodyBytes))
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(bodyBytes))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Authorization", auth)
+	req.Header.Set("User-Agent", c.UserAgent)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("wxpay request failed: status=%d body=%s", resp.StatusCode, string(respBody))
+	}
+	if out == nil {
+		return nil
+	}
+	if err := json.Unmarshal(respBody, out); err != nil {
+		return fmt.Errorf("parse wxpay response failed: %w, body=%s", err, string(respBody))
+	}
+	return nil
+}
+
+func (c *WxPayClient) buildAuthorization(method, fullURL, body string) (string, error) {
+	u, err := url.Parse(fullURL)
+	if err != nil {
+		return "", err
+	}
+	canonicalURL := u.Path
+	if u.RawQuery != "" {
+		canonicalURL += "?" + u.RawQuery
+	}
+	nonce := randomString(32)
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	message := method + "\n" + canonicalURL + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
+
+	signature, err := c.signBase64(message)
+	if err != nil {
+		return "", err
+	}
+	token := fmt.Sprintf(`mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`,
+		c.MchID, nonce, timestamp, c.SerialNo, signature,
+	)
+	return "WECHATPAY2-SHA256-RSA2048 " + token, nil
+}
+
+func (c *WxPayClient) signBase64(message string) (string, error) {
+	sum := sha256.Sum256([]byte(message))
+	sign, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, sum[:])
+	if err != nil {
+		return "", err
+	}
+	return base64.StdEncoding.EncodeToString(sign), nil
+}
+
+func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	block, _ := pem.Decode(raw)
+	if block == nil {
+		return nil, errors.New("invalid private key pem")
+	}
+
+	if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
+		if rsaKey, ok := key.(*rsa.PrivateKey); ok {
+			return rsaKey, nil
+		}
+	}
+	if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+		return key, nil
+	}
+
+	return nil, errors.New("unsupported private key format")
+}
+
+func randomString(n int) string {
+	const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	if n <= 0 {
+		return ""
+	}
+	b := make([]byte, n)
+	rb := make([]byte, n)
+	if _, err := rand.Read(rb); err != nil {
+		return strconv.FormatInt(time.Now().UnixNano(), 10)
+	}
+	for i := 0; i < n; i++ {
+		b[i] = chars[int(rb[i])%len(chars)]
+	}
+	return string(b)
+}
+
+func firstNotEmpty(values ...string) string {
+	for _, v := range values {
+		if strings.TrimSpace(v) != "" {
+			return strings.TrimSpace(v)
+		}
+	}
+	return ""
+}

+ 475 - 0
utils/wechat_pay.go.4134775129706849525

@@ -0,0 +1,475 @@
+package utils
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type WxPayClient struct {
+	MchID        string
+	SerialNo     string
+	NotifyURL    string
+	JSAPIAppID   string
+	H5AppID      string
+	AppAppID     string
+	PrivateKey   *rsa.PrivateKey
+	HTTPClient   *http.Client
+	APIBase      string
+	Enabled      bool
+	UserAgent    string
+}
+
+type WxPayAmount struct {
+	Total    int    `json:"total"`
+	Currency string `json:"currency,omitempty"`
+}
+
+type WxPayPayer struct {
+	OpenID string `json:"openid,omitempty"`
+}
+
+type WxPayH5Info struct {
+	Type string `json:"type"`
+}
+
+type WxPaySceneInfo struct {
+	PayerClientIP string      `json:"payer_client_ip,omitempty"`
+	H5Info        *WxPayH5Info `json:"h5_info,omitempty"`
+}
+
+type wxPayTxnRequest struct {
+	AppID       string         `json:"appid"`
+	MchID       string         `json:"mchid"`
+	Description string         `json:"description"`
+	OutTradeNo  string         `json:"out_trade_no"`
+	NotifyURL   string         `json:"notify_url"`
+	Attach      string         `json:"attach,omitempty"`
+	Amount      WxPayAmount    `json:"amount"`
+	Payer       *WxPayPayer    `json:"payer,omitempty"`
+	SceneInfo   *WxPaySceneInfo `json:"scene_info,omitempty"`
+}
+
+type wxPayTxnResp struct {
+	PrepayID string `json:"prepay_id"`
+	H5URL    string `json:"h5_url"`
+}
+
+type WxMiniProgramOrderReq struct {
+	Description string
+	OutTradeNo  string
+	Total       int
+	Currency    string
+	OpenID      string
+	NotifyURL   string
+	Attach      string
+	AppID       string
+}
+
+type WxMiniProgramPayResult struct {
+	AppID     string `json:"appId"`
+	TimeStamp string `json:"timeStamp"`
+	NonceStr  string `json:"nonceStr"`
+	Package   string `json:"package"`
+	SignType  string `json:"signType"`
+	PaySign   string `json:"paySign"`
+	PrepayID  string `json:"prepayId"`
+}
+
+type WxH5OrderReq struct {
+	Description   string
+	OutTradeNo    string
+	Total         int
+	Currency      string
+	NotifyURL     string
+	Attach        string
+	PayerClientIP string
+	AppID         string
+}
+
+type WxH5PayResult struct {
+	H5URL string `json:"h5Url"`
+}
+
+type WxAppOrderReq struct {
+	Description string
+	OutTradeNo  string
+	Total       int
+	Currency    string
+	NotifyURL   string
+	Attach      string
+	AppID       string
+}
+
+type WxAppPayResult struct {
+	AppID     string `json:"appid"`
+	PartnerID string `json:"partnerid"`
+	PrepayID  string `json:"prepayid"`
+	Package   string `json:"package"`
+	NonceStr  string `json:"noncestr"`
+	TimeStamp string `json:"timestamp"`
+	Sign      string `json:"sign"`
+}
+
+var wxPayClient *WxPayClient
+
+func InitWxPay() error {
+	enabled := redisBool("wxpay_enable", false)
+	if !enabled {
+		return nil
+	}
+
+	mchID := redisString("wxpay_mch_id", "")
+	serialNo := redisString("wxpay_serial_no", "")
+	privateKeyPath := redisString("wxpay_private_key_path", "")
+	notifyURL := redisString("wxpay_notify_url", "")
+	jsapiAppID := redisString("wxpay_jsapi_appid", "")
+	h5AppID := redisString("wxpay_h5_appid", "")
+	appAppID := redisString("wxpay_app_appid", "")
+	apiBase := redisString("wxpay_api_base", "https://api.mch.weixin.qq.com")
+
+	if mchID == "" || serialNo == "" || privateKeyPath == "" || notifyURL == "" {
+		return errors.New("wxpay config missing: wxpay_mch_id/wxpay_serial_no/wxpay_private_key_path/wxpay_notify_url are required")
+	}
+
+	privateKey, err := loadRSAPrivateKey(privateKeyPath)
+	if err != nil {
+		return err
+	}
+
+	wxPayClient = &WxPayClient{
+		MchID:      mchID,
+		SerialNo:   serialNo,
+		NotifyURL:  notifyURL,
+		JSAPIAppID: jsapiAppID,
+		H5AppID:    h5AppID,
+		AppAppID:   appAppID,
+		PrivateKey: privateKey,
+		HTTPClient: &http.Client{Timeout: 15 * time.Second},
+		APIBase:    strings.TrimRight(apiBase, "/"),
+		Enabled:    true,
+		UserAgent:  "think-go-wxpay/1.0",
+	}
+
+	return nil
+}
+
+func GetWxPayClient() (*WxPayClient, error) {
+	if wxPayClient == nil || !wxPayClient.Enabled {
+		return nil, errors.New("wxpay not initialized, set wxpay_enable=true and call InitWxPay")
+	}
+	return wxPayClient, nil
+}
+
+func CreateWxMiniProgramPay(req WxMiniProgramOrderReq) (*WxMiniProgramPayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateMiniProgramPay(req)
+}
+
+func CreateWxH5Pay(req WxH5OrderReq) (*WxH5PayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateH5Pay(req)
+}
+
+func CreateWxAppPay(req WxAppOrderReq) (*WxAppPayResult, error) {
+	client, err := GetWxPayClient()
+	if err != nil {
+		return nil, err
+	}
+	return client.CreateAppPay(req)
+}
+
+func (c *WxPayClient) CreateMiniProgramPay(req WxMiniProgramOrderReq) (*WxMiniProgramPayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.JSAPIAppID
+	}
+	if appID == "" {
+		return nil, errors.New("jsapi appid is required")
+	}
+	if req.OpenID == "" {
+		return nil, errors.New("openid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+		Payer: &WxPayPayer{OpenID: req.OpenID},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/jsapi", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.PrepayID == "" {
+		return nil, errors.New("wxpay jsapi response missing prepay_id")
+	}
+
+	timeStamp := strconv.FormatInt(time.Now().Unix(), 10)
+	nonceStr := randomString(32)
+	pkg := "prepay_id=" + resp.PrepayID
+	signPayload := appID + "\n" + timeStamp + "\n" + nonceStr + "\n" + pkg + "\n"
+	paySign, err := c.signBase64(signPayload)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WxMiniProgramPayResult{
+		AppID:     appID,
+		TimeStamp: timeStamp,
+		NonceStr:  nonceStr,
+		Package:   pkg,
+		SignType:  "RSA",
+		PaySign:   paySign,
+		PrepayID:  resp.PrepayID,
+	}, nil
+}
+
+func (c *WxPayClient) CreateH5Pay(req WxH5OrderReq) (*WxH5PayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.H5AppID
+	}
+	if appID == "" {
+		return nil, errors.New("h5 appid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+	if req.PayerClientIP == "" {
+		return nil, errors.New("payer_client_ip is required for h5 pay")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+		SceneInfo: &WxPaySceneInfo{
+			PayerClientIP: req.PayerClientIP,
+			H5Info:        &WxPayH5Info{Type: "Wap"},
+		},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/h5", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.H5URL == "" {
+		return nil, errors.New("wxpay h5 response missing h5_url")
+	}
+	return &WxH5PayResult{H5URL: resp.H5URL}, nil
+}
+
+func (c *WxPayClient) CreateAppPay(req WxAppOrderReq) (*WxAppPayResult, error) {
+	appID := req.AppID
+	if appID == "" {
+		appID = c.AppAppID
+	}
+	if appID == "" {
+		return nil, errors.New("app appid is required")
+	}
+	if req.OutTradeNo == "" || req.Description == "" || req.Total <= 0 {
+		return nil, errors.New("description/outTradeNo/total are required")
+	}
+
+	requestBody := wxPayTxnRequest{
+		AppID:       appID,
+		MchID:       c.MchID,
+		Description: req.Description,
+		OutTradeNo:  req.OutTradeNo,
+		NotifyURL:   firstNotEmpty(req.NotifyURL, c.NotifyURL),
+		Attach:      req.Attach,
+		Amount: WxPayAmount{
+			Total:    req.Total,
+			Currency: firstNotEmpty(req.Currency, "CNY"),
+		},
+	}
+
+	var resp wxPayTxnResp
+	if err := c.postJSON("/v3/pay/transactions/app", requestBody, &resp); err != nil {
+		return nil, err
+	}
+	if resp.PrepayID == "" {
+		return nil, errors.New("wxpay app response missing prepay_id")
+	}
+
+	timeStamp := strconv.FormatInt(time.Now().Unix(), 10)
+	nonceStr := randomString(32)
+	signPayload := appID + "\n" + timeStamp + "\n" + nonceStr + "\n" + resp.PrepayID + "\n"
+	sign, err := c.signBase64(signPayload)
+	if err != nil {
+		return nil, err
+	}
+
+	return &WxAppPayResult{
+		AppID:     appID,
+		PartnerID: c.MchID,
+		PrepayID:  resp.PrepayID,
+		Package:   "Sign=WXPay",
+		NonceStr:  nonceStr,
+		TimeStamp: timeStamp,
+		Sign:      sign,
+	}, nil
+}
+
+func (c *WxPayClient) postJSON(path string, payload interface{}, out interface{}) error {
+	bodyBytes, err := json.Marshal(payload)
+	if err != nil {
+		return err
+	}
+	fullURL := c.APIBase + path
+
+	auth, err := c.buildAuthorization("POST", fullURL, string(bodyBytes))
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(bodyBytes))
+	if err != nil {
+		return err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	req.Header.Set("Authorization", auth)
+	req.Header.Set("User-Agent", c.UserAgent)
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return err
+	}
+
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return fmt.Errorf("wxpay request failed: status=%d body=%s", resp.StatusCode, string(respBody))
+	}
+	if out == nil {
+		return nil
+	}
+	if err := json.Unmarshal(respBody, out); err != nil {
+		return fmt.Errorf("parse wxpay response failed: %w, body=%s", err, string(respBody))
+	}
+	return nil
+}
+
+func (c *WxPayClient) buildAuthorization(method, fullURL, body string) (string, error) {
+	u, err := url.Parse(fullURL)
+	if err != nil {
+		return "", err
+	}
+	canonicalURL := u.Path
+	if u.RawQuery != "" {
+		canonicalURL += "?" + u.RawQuery
+	}
+	nonce := randomString(32)
+	timestamp := strconv.FormatInt(time.Now().Unix(), 10)
+	message := method + "\n" + canonicalURL + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
+
+	signature, err := c.signBase64(message)
+	if err != nil {
+		return "", err
+	}
+	token := fmt.Sprintf(`mchid="%s",nonce_str="%s",timestamp="%s",serial_no="%s",signature="%s"`,
+		c.MchID, nonce, timestamp, c.SerialNo, signature,
+	)
+	return "WECHATPAY2-SHA256-RSA2048 " + token, nil
+}
+
+func (c *WxPayClient) signBase64(message string) (string, error) {
+	sum := sha256.Sum256([]byte(message))
+	sign, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, sum[:])
+	if err != nil {
+		return "", err
+	}
+	return base64.StdEncoding.EncodeToString(sign), nil
+}
+
+func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) {
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	block, _ := pem.Decode(raw)
+	if block == nil {
+		return nil, errors.New("invalid private key pem")
+	}
+
+	if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
+		if rsaKey, ok := key.(*rsa.PrivateKey); ok {
+			return rsaKey, nil
+		}
+	}
+	if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+		return key, nil
+	}
+
+	return nil, errors.New("unsupported private key format")
+}
+
+func randomString(n int) string {
+	const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	if n <= 0 {
+		return ""
+	}
+	b := make([]byte, n)
+	rb := make([]byte, n)
+	if _, err := rand.Read(rb); err != nil {
+		return strconv.FormatInt(time.Now().UnixNano(), 10)
+	}
+	for i := 0; i < n; i++ {
+		b[i] = chars[int(rb[i])%len(chars)]
+	}
+	return string(b)
+}
+
+func firstNotEmpty(values ...string) string {
+	for _, v := range values {
+		if strings.TrimSpace(v) != "" {
+			return strings.TrimSpace(v)
+		}
+	}
+	return ""
+}

+ 270 - 0
views/admin/kline.tpl

@@ -0,0 +1,270 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>专业K线图</title>
+  <script src="https://cdn.jsdelivr.net/npm/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
+  <style>
+    :root {
+      --bg: #0b1220;
+      --panel: #111a2e;
+      --line: #273552;
+      --text: #dbe8ff;
+      --muted: #8fa4c7;
+      --accent: #2ec7ff;
+      --up: #00c087;
+      --down: #f6465d;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      padding: 16px;
+      min-height: 100vh;
+      color: var(--text);
+      background: radial-gradient(circle at 15% 10%, #1a2740 0%, #0f172a 45%, #0b1220 100%);
+      font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+    }
+    .wrap { max-width: 1320px; margin: 0 auto; display: grid; gap: 12px; }
+    .toolbar {
+      background: rgba(17, 26, 46, 0.92);
+      border: 1px solid var(--line);
+      border-radius: 12px;
+      padding: 12px;
+      display: grid;
+      gap: 10px;
+      grid-template-columns: 1fr auto auto;
+      align-items: center;
+    }
+    .left { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+    .intervals { display: flex; flex-wrap: wrap; gap: 8px; }
+    input, select, button {
+      height: 36px;
+      border: 1px solid var(--line);
+      border-radius: 9px;
+      background: #0d1628;
+      color: var(--text);
+      padding: 0 12px;
+      font-size: 14px;
+      outline: none;
+    }
+    button { cursor: pointer; font-weight: 600; }
+    .primary {
+      background: linear-gradient(135deg, #0ea5e9, #0284c7);
+      border-color: #38bdf8;
+      color: #e8f8ff;
+    }
+    .interval-btn.active {
+      border-color: var(--accent);
+      color: #e9f7ff;
+      box-shadow: 0 0 0 1px #1a8fb8 inset;
+    }
+    .status { color: var(--muted); min-width: 280px; text-align: right; font-size: 13px; }
+    .panel {
+      background: rgba(17, 26, 46, 0.92);
+      border: 1px solid var(--line);
+      border-radius: 12px;
+      overflow: hidden;
+    }
+    #chart { width: 100%; height: min(80vh, 800px); }
+    @media (max-width: 980px) {
+      .toolbar { grid-template-columns: 1fr; }
+      .status { text-align: left; min-width: 0; }
+      #chart { height: 72vh; }
+    }
+  </style>
+</head>
+<body>
+  <main class="wrap">
+    <section class="toolbar">
+      <div class="left">
+        <input id="symbolInput" value="BTCUSDT" placeholder="币对,如 BTCUSDT" />
+        <select id="limitSelect">
+          <option value="100">100</option>
+          <option value="200" selected>200</option>
+          <option value="500">500</option>
+          <option value="1000">1000</option>
+        </select>
+        <button id="refreshBtn" class="primary">刷新</button>
+      </div>
+      <div class="intervals" id="intervals">
+        <button class="interval-btn active" data-v="hour">1小时</button>
+        <button class="interval-btn" data-v="day">1天</button>
+        <button class="interval-btn" data-v="week">1周</button>
+        <button class="interval-btn" data-v="month">1月</button>
+      </div>
+      <div id="status" class="status">等待加载...</div>
+    </section>
+
+    <section class="panel">
+      <div id="chart"></div>
+    </section>
+  </main>
+
+  <script>
+    (() => {
+      const chartEl = document.getElementById('chart');
+      const statusEl = document.getElementById('status');
+      const symbolInput = document.getElementById('symbolInput');
+      const limitSelect = document.getElementById('limitSelect');
+      const refreshBtn = document.getElementById('refreshBtn');
+      const intervalsEl = document.getElementById('intervals');
+
+      let currentInterval = 'hour';
+      let timer = null;
+
+      const chart = LightweightCharts.createChart(chartEl, {
+        autoSize: true,
+        layout: {
+          background: { color: '#101a30' },
+          textColor: '#93a8cc',
+          fontFamily: 'Segoe UI, PingFang SC, Microsoft YaHei, sans-serif'
+        },
+        rightPriceScale: {
+          borderColor: '#2a3a58',
+          scaleMargins: { top: 0.08, bottom: 0.26 }
+        },
+        timeScale: {
+          borderColor: '#2a3a58',
+          rightOffset: 6,
+          barSpacing: 10,
+          fixLeftEdge: false,
+          lockVisibleTimeRangeOnResize: true,
+          secondsVisible: false,
+          timeVisible: true
+        },
+        grid: {
+          vertLines: { color: 'rgba(78, 97, 130, 0.20)' },
+          horzLines: { color: 'rgba(78, 97, 130, 0.20)' }
+        },
+        crosshair: {
+          mode: LightweightCharts.CrosshairMode.Normal,
+          vertLine: { color: '#466285', width: 1, style: 3 },
+          horzLine: { color: '#466285', width: 1, style: 3 }
+        }
+      });
+
+      const candleSeries = chart.addCandlestickSeries({
+        upColor: '#00c087',
+        downColor: '#f6465d',
+        borderUpColor: '#00c087',
+        borderDownColor: '#f6465d',
+        wickUpColor: '#00c087',
+        wickDownColor: '#f6465d',
+        priceLineVisible: true
+      });
+
+      const volumeSeries = chart.addHistogramSeries({
+        priceFormat: { type: 'volume' },
+        priceScaleId: '',
+        color: '#4a90e2',
+        base: 0,
+        lastValueVisible: false,
+        priceLineVisible: false
+      });
+      volumeSeries.priceScale().applyOptions({
+        scaleMargins: { top: 0.80, bottom: 0 }
+      });
+
+      const ma5 = chart.addLineSeries({ color: '#f5a524', lineWidth: 1.4, crosshairMarkerVisible: false, priceLineVisible: false });
+      const ma10 = chart.addLineSeries({ color: '#4da3ff', lineWidth: 1.2, crosshairMarkerVisible: false, priceLineVisible: false });
+      const ma30 = chart.addLineSeries({ color: '#a68bff', lineWidth: 1.2, crosshairMarkerVisible: false, priceLineVisible: false });
+
+      function setStatus(text) { statusEl.textContent = text; }
+
+      function toUnixSeconds(ms) {
+        return Math.floor(Number(ms) / 1000);
+      }
+
+      function calcMA(day, list) {
+        const out = [];
+        for (let i = 0; i < list.length; i++) {
+          if (i < day - 1) continue;
+          let sum = 0;
+          for (let j = 0; j < day; j++) sum += Number(list[i - j].close);
+          out.push({ time: list[i].time, value: Number((sum / day).toFixed(6)) });
+        }
+        return out;
+      }
+
+      async function loadKline() {
+        const symbol = (symbolInput.value || 'BTCUSDT').trim().toUpperCase();
+        const limit = limitSelect.value;
+        const url = `/admin/coin/kline?symbol=${encodeURIComponent(symbol)}&interval=${encodeURIComponent(currentInterval)}&limit=${encodeURIComponent(limit)}`;
+
+        setStatus('加载中...');
+
+        try {
+          const resp = await fetch(url, { cache: 'no-store' });
+          const json = await resp.json();
+          if (json.code !== 200) {
+            setStatus(`加载失败: ${json.data || json.msg || 'unknown error'}`);
+            return;
+          }
+
+          const items = (json.data && json.data.items) ? json.data.items : [];
+          if (!items.length) {
+            setStatus('无数据');
+            candleSeries.setData([]);
+            volumeSeries.setData([]);
+            ma5.setData([]); ma10.setData([]); ma30.setData([]);
+            return;
+          }
+
+          const k = items.map(it => ({
+            time: toUnixSeconds(it.open_time),
+            open: Number(it.open),
+            high: Number(it.high),
+            low: Number(it.low),
+            close: Number(it.close),
+            volume: Number(it.volume)
+          }));
+
+          candleSeries.setData(k.map(x => ({
+            time: x.time,
+            open: x.open,
+            high: x.high,
+            low: x.low,
+            close: x.close
+          })));
+
+          volumeSeries.setData(k.map(x => ({
+            time: x.time,
+            value: x.volume,
+            color: x.close >= x.open ? 'rgba(0,192,135,0.58)' : 'rgba(246,70,93,0.58)'
+          })));
+
+          ma5.setData(calcMA(5, k));
+          ma10.setData(calcMA(10, k));
+          ma30.setData(calcMA(30, k));
+
+          chart.timeScale().fitContent();
+          setStatus(`${symbol} · ${currentInterval} · ${k.length}根K线 · ${new Date().toLocaleTimeString()}`);
+        } catch (e) {
+          setStatus(`请求失败: ${e.message}`);
+        }
+      }
+
+      intervalsEl.addEventListener('click', (e) => {
+        const btn = e.target.closest('.interval-btn');
+        if (!btn) return;
+        document.querySelectorAll('.interval-btn').forEach(x => x.classList.remove('active'));
+        btn.classList.add('active');
+        currentInterval = btn.dataset.v;
+        loadKline();
+      });
+
+      refreshBtn.addEventListener('click', loadKline);
+      symbolInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') loadKline(); });
+
+      function startAuto() {
+        if (timer) clearInterval(timer);
+        timer = setInterval(loadKline, 15000);
+      }
+
+      startAuto();
+      loadKline();
+    })();
+  </script>
+</body>
+</html>

+ 500 - 0
views/admin/tetris.tpl

@@ -0,0 +1,500 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>俄罗斯方块</title>
+  <style>
+    :root {
+      --bg-a: #0f172a;
+      --bg-b: #111827;
+      --panel: rgba(17, 24, 39, 0.86);
+      --line: #334155;
+      --text: #e2e8f0;
+      --muted: #94a3b8;
+      --accent: #22d3ee;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      min-height: 100vh;
+      font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+      color: var(--text);
+      background: radial-gradient(circle at 20% 20%, #1e293b 0%, var(--bg-a) 45%, var(--bg-b) 100%);
+      display: grid;
+      place-items: center;
+      padding: 20px;
+    }
+    .wrap {
+      width: min(920px, 100%);
+      display: grid;
+      grid-template-columns: minmax(280px, 420px) minmax(240px, 1fr);
+      gap: 20px;
+    }
+    .panel {
+      background: var(--panel);
+      border: 1px solid var(--line);
+      border-radius: 16px;
+      box-shadow: 0 12px 40px rgba(2, 6, 23, 0.4);
+      overflow: hidden;
+    }
+    .board {
+      padding: 16px;
+      display: grid;
+      place-items: center;
+    }
+    #game {
+      width: 100%;
+      max-width: 360px;
+      aspect-ratio: 1 / 2;
+      background: #020617;
+      border: 1px solid #1e293b;
+      border-radius: 12px;
+      display: block;
+    }
+    .side {
+      padding: 20px;
+      display: grid;
+      gap: 16px;
+      align-content: start;
+    }
+    h1 {
+      margin: 0;
+      font-size: 26px;
+      letter-spacing: 0.5px;
+    }
+    .sub {
+      color: var(--muted);
+      font-size: 14px;
+      margin-top: 4px;
+    }
+    .stats {
+      display: grid;
+      grid-template-columns: repeat(2, minmax(120px, 1fr));
+      gap: 10px;
+    }
+    .card {
+      border: 1px solid var(--line);
+      border-radius: 12px;
+      padding: 12px;
+      background: rgba(2, 6, 23, 0.35);
+    }
+    .label { color: var(--muted); font-size: 12px; margin-bottom: 6px; }
+    .value { font-size: 22px; font-weight: 700; color: #f8fafc; }
+    .next-box {
+      border: 1px solid var(--line);
+      border-radius: 12px;
+      padding: 12px;
+      background: rgba(2, 6, 23, 0.35);
+    }
+    #next {
+      width: 100%;
+      max-width: 180px;
+      aspect-ratio: 1 / 1;
+      background: #020617;
+      border-radius: 10px;
+      border: 1px solid #1e293b;
+      display: block;
+    }
+    .btns {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px;
+    }
+    button {
+      cursor: pointer;
+      border: 1px solid var(--line);
+      background: #0b1220;
+      color: var(--text);
+      padding: 9px 14px;
+      border-radius: 10px;
+      font-weight: 600;
+    }
+    button.primary {
+      background: linear-gradient(135deg, #0891b2, #06b6d4);
+      color: #062026;
+      border-color: #22d3ee;
+    }
+    .keys {
+      border: 1px dashed var(--line);
+      border-radius: 12px;
+      padding: 12px;
+      color: var(--muted);
+      font-size: 13px;
+      line-height: 1.7;
+      background: rgba(2, 6, 23, 0.2);
+    }
+    .status {
+      color: var(--accent);
+      font-weight: 700;
+      font-size: 14px;
+      min-height: 20px;
+    }
+    @media (max-width: 760px) {
+      .wrap { grid-template-columns: 1fr; }
+      .board { order: 1; }
+      .side { order: 2; }
+    }
+  </style>
+</head>
+<body>
+  <main class="wrap">
+    <section class="panel board">
+      <canvas id="game" width="300" height="600"></canvas>
+    </section>
+
+    <aside class="panel side">
+      <div>
+        <h1>俄罗斯方块</h1>
+        <div class="sub">路径: /admin/game/tetris</div>
+      </div>
+
+      <div class="stats">
+        <div class="card">
+          <div class="label">分数</div>
+          <div class="value" id="score">0</div>
+        </div>
+        <div class="card">
+          <div class="label">等级</div>
+          <div class="value" id="level">1</div>
+        </div>
+        <div class="card">
+          <div class="label">消除行数</div>
+          <div class="value" id="lines">0</div>
+        </div>
+        <div class="card">
+          <div class="label">最高分</div>
+          <div class="value" id="best">0</div>
+        </div>
+      </div>
+
+      <div class="next-box">
+        <div class="label">下一个方块</div>
+        <canvas id="next" width="160" height="160"></canvas>
+      </div>
+
+      <div class="btns">
+        <button class="primary" id="startBtn">开始 / 重新开始</button>
+        <button id="pauseBtn">暂停</button>
+      </div>
+
+      <div class="status" id="status">按“开始”后即可游戏</div>
+
+      <div class="keys">
+        ← / → : 左右移动<br>
+        ↑ : 旋转<br>
+        ↓ : 加速下落<br>
+        空格 : 直接落到底<br>
+        P : 暂停 / 继续
+      </div>
+    </aside>
+  </main>
+
+  <script>
+    (function () {
+      const COLS = 10;
+      const ROWS = 20;
+      const BLOCK = 30;
+
+      const SHAPES = {
+        I: [[1, 1, 1, 1]],
+        O: [[1, 1], [1, 1]],
+        T: [[0, 1, 0], [1, 1, 1]],
+        S: [[0, 1, 1], [1, 1, 0]],
+        Z: [[1, 1, 0], [0, 1, 1]],
+        J: [[1, 0, 0], [1, 1, 1]],
+        L: [[0, 0, 1], [1, 1, 1]]
+      };
+
+      const COLORS = {
+        I: '#38bdf8',
+        O: '#facc15',
+        T: '#a78bfa',
+        S: '#4ade80',
+        Z: '#fb7185',
+        J: '#60a5fa',
+        L: '#fb923c'
+      };
+
+      const gameCanvas = document.getElementById('game');
+      const gameCtx = gameCanvas.getContext('2d');
+      const nextCanvas = document.getElementById('next');
+      const nextCtx = nextCanvas.getContext('2d');
+
+      const scoreEl = document.getElementById('score');
+      const levelEl = document.getElementById('level');
+      const linesEl = document.getElementById('lines');
+      const bestEl = document.getElementById('best');
+      const statusEl = document.getElementById('status');
+      const startBtn = document.getElementById('startBtn');
+      const pauseBtn = document.getElementById('pauseBtn');
+
+      const BEST_KEY = 'tetris_best_score_v1';
+
+      let board = [];
+      let current = null;
+      let next = null;
+      let dropCounter = 0;
+      let lastTime = 0;
+      let isRunning = false;
+      let isPaused = false;
+
+      let score = 0;
+      let lines = 0;
+      let level = 1;
+      let best = Number(localStorage.getItem(BEST_KEY) || 0);
+      bestEl.textContent = String(best);
+
+      function createBoard() {
+        return Array.from({ length: ROWS }, () => Array(COLS).fill(null));
+      }
+
+      function pickType() {
+        const types = Object.keys(SHAPES);
+        return types[Math.floor(Math.random() * types.length)];
+      }
+
+      function createPiece(type) {
+        return {
+          type,
+          shape: SHAPES[type].map(row => row.slice()),
+          x: Math.floor(COLS / 2) - Math.ceil(SHAPES[type][0].length / 2),
+          y: 0
+        };
+      }
+
+      function rotate(shape) {
+        const h = shape.length;
+        const w = shape[0].length;
+        const out = Array.from({ length: w }, () => Array(h).fill(0));
+        for (let y = 0; y < h; y++) {
+          for (let x = 0; x < w; x++) {
+            out[x][h - y - 1] = shape[y][x];
+          }
+        }
+        return out;
+      }
+
+      function collides(piece, dx = 0, dy = 0, shape = piece.shape) {
+        for (let y = 0; y < shape.length; y++) {
+          for (let x = 0; x < shape[y].length; x++) {
+            if (!shape[y][x]) continue;
+            const nx = piece.x + x + dx;
+            const ny = piece.y + y + dy;
+            if (nx < 0 || nx >= COLS || ny >= ROWS) return true;
+            if (ny >= 0 && board[ny][nx]) return true;
+          }
+        }
+        return false;
+      }
+
+      function merge(piece) {
+        for (let y = 0; y < piece.shape.length; y++) {
+          for (let x = 0; x < piece.shape[y].length; x++) {
+            if (piece.shape[y][x]) {
+              const by = piece.y + y;
+              const bx = piece.x + x;
+              if (by >= 0) board[by][bx] = piece.type;
+            }
+          }
+        }
+      }
+
+      function clearLines() {
+        let cleared = 0;
+        for (let y = ROWS - 1; y >= 0; y--) {
+          if (board[y].every(cell => cell !== null)) {
+            board.splice(y, 1);
+            board.unshift(Array(COLS).fill(null));
+            cleared++;
+            y++;
+          }
+        }
+        if (cleared > 0) {
+          lines += cleared;
+          score += [0, 100, 300, 500, 800][cleared] * level;
+          level = 1 + Math.floor(lines / 10);
+          refreshStats();
+        }
+      }
+
+      function refreshStats() {
+        scoreEl.textContent = String(score);
+        linesEl.textContent = String(lines);
+        levelEl.textContent = String(level);
+      }
+
+      function spawn() {
+        if (!next) next = createPiece(pickType());
+        current = next;
+        current.x = Math.floor(COLS / 2) - Math.ceil(current.shape[0].length / 2);
+        current.y = 0;
+        next = createPiece(pickType());
+        drawNext();
+
+        if (collides(current, 0, 0)) {
+          isRunning = false;
+          statusEl.textContent = '游戏结束,点击“开始 / 重新开始”再来一局';
+          if (score > best) {
+            best = score;
+            localStorage.setItem(BEST_KEY, String(best));
+            bestEl.textContent = String(best);
+          }
+        }
+      }
+
+      function move(dx) {
+        if (!current || !isRunning || isPaused) return;
+        if (!collides(current, dx, 0)) current.x += dx;
+      }
+
+      function softDrop() {
+        if (!current || !isRunning || isPaused) return;
+        if (!collides(current, 0, 1)) {
+          current.y += 1;
+        } else {
+          lockAndContinue();
+        }
+      }
+
+      function hardDrop() {
+        if (!current || !isRunning || isPaused) return;
+        while (!collides(current, 0, 1)) {
+          current.y += 1;
+          score += 2;
+        }
+        refreshStats();
+        lockAndContinue();
+      }
+
+      function lockAndContinue() {
+        merge(current);
+        clearLines();
+        spawn();
+      }
+
+      function rotateCurrent() {
+        if (!current || !isRunning || isPaused) return;
+        const rotated = rotate(current.shape);
+        if (!collides(current, 0, 0, rotated)) {
+          current.shape = rotated;
+          return;
+        }
+        if (!collides(current, -1, 0, rotated)) {
+          current.x -= 1;
+          current.shape = rotated;
+          return;
+        }
+        if (!collides(current, 1, 0, rotated)) {
+          current.x += 1;
+          current.shape = rotated;
+        }
+      }
+
+      function drawCell(ctx, x, y, color, size) {
+        ctx.fillStyle = color;
+        ctx.fillRect(x * size, y * size, size, size);
+        ctx.strokeStyle = 'rgba(15, 23, 42, 0.8)';
+        ctx.lineWidth = 1;
+        ctx.strokeRect(x * size + 0.5, y * size + 0.5, size - 1, size - 1);
+      }
+
+      function drawBoard() {
+        gameCtx.fillStyle = '#020617';
+        gameCtx.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
+
+        for (let y = 0; y < ROWS; y++) {
+          for (let x = 0; x < COLS; x++) {
+            const t = board[y][x];
+            if (t) drawCell(gameCtx, x, y, COLORS[t], BLOCK);
+          }
+        }
+
+        if (current) {
+          for (let y = 0; y < current.shape.length; y++) {
+            for (let x = 0; x < current.shape[y].length; x++) {
+              if (current.shape[y][x]) {
+                drawCell(gameCtx, current.x + x, current.y + y, COLORS[current.type], BLOCK);
+              }
+            }
+          }
+        }
+      }
+
+      function drawNext() {
+        nextCtx.fillStyle = '#020617';
+        nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
+        if (!next) return;
+
+        const size = 32;
+        const shape = next.shape;
+        const offsetX = Math.floor((nextCanvas.width - shape[0].length * size) / 2 / size);
+        const offsetY = Math.floor((nextCanvas.height - shape.length * size) / 2 / size);
+
+        for (let y = 0; y < shape.length; y++) {
+          for (let x = 0; x < shape[y].length; x++) {
+            if (shape[y][x]) drawCell(nextCtx, offsetX + x, offsetY + y, COLORS[next.type], size);
+          }
+        }
+      }
+
+      function getDropInterval() {
+        return Math.max(100, 800 - (level - 1) * 60);
+      }
+
+      function update(time = 0) {
+        const delta = time - lastTime;
+        lastTime = time;
+
+        if (isRunning && !isPaused) {
+          dropCounter += delta;
+          if (dropCounter >= getDropInterval()) {
+            softDrop();
+            dropCounter = 0;
+          }
+        }
+
+        drawBoard();
+        requestAnimationFrame(update);
+      }
+
+      function startGame() {
+        board = createBoard();
+        score = 0;
+        lines = 0;
+        level = 1;
+        isRunning = true;
+        isPaused = false;
+        refreshStats();
+        statusEl.textContent = '游戏进行中';
+        next = createPiece(pickType());
+        spawn();
+      }
+
+      function togglePause() {
+        if (!isRunning) return;
+        isPaused = !isPaused;
+        statusEl.textContent = isPaused ? '已暂停' : '游戏进行中';
+      }
+
+      document.addEventListener('keydown', (e) => {
+        if (!isRunning) return;
+        if (e.code === 'ArrowLeft') { e.preventDefault(); move(-1); }
+        if (e.code === 'ArrowRight') { e.preventDefault(); move(1); }
+        if (e.code === 'ArrowDown') { e.preventDefault(); softDrop(); }
+        if (e.code === 'ArrowUp') { e.preventDefault(); rotateCurrent(); }
+        if (e.code === 'Space') { e.preventDefault(); hardDrop(); }
+        if (e.code === 'KeyP') { e.preventDefault(); togglePause(); }
+      });
+
+      startBtn.addEventListener('click', startGame);
+      pauseBtn.addEventListener('click', togglePause);
+
+      board = createBoard();
+      drawBoard();
+      drawNext();
+      requestAnimationFrame(update);
+    })();
+  </script>
+</body>
+</html>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 6 - 0
views/index.tpl


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä