Initial commit

This commit is contained in:
2024-08-12 04:10:48 +00:00
commit a55ef32347
22 changed files with 2410 additions and 0 deletions

BIN
aigrammar Executable file

Binary file not shown.

18
comm.go Normal file
View File

@ -0,0 +1,18 @@
package main
// 定义公共变量名
const (
// 从jwt中解析出来的字段放到 context 中
KEY_DEVICEID = "DeviceID"
KEY_GID = "GID"
KEY_HEADER_TIMEZONE = "timezone"
KEY_HEADER_SECONDSFROMGMT = "secondsfromgmt"
// 定义每日免费次数
DAILY_FREE_COUNT = 3
// 定义购买的应用商店
APPSTORE = "appstore"
PLAYSTORE = "playstore"
)

155
config.go Normal file
View File

@ -0,0 +1,155 @@
package main
import (
"errors"
"reflect"
"strings"
"sync"
"github.com/spf13/viper"
)
type AzureOpenAIConfig struct {
Endpoint string `mapstructure:"endpoint"`
Keys []string `mapstructure:"keys"`
GPT4Model string `mapstructure:"gpt4_model"`
GPT35Model string `mapstructure:"gpt35_model"`
}
type OpenAIConfig struct {
APIKey string `mapstructure:"api_key"`
Organization string `mapstructure:"organization"`
}
type AIGrammarBaseConfig struct {
// 在 Go 中,只有首字母大写的字段才能被外部包(如 viper访问。
JwtSecret string `mapstructure:"jwt_secret"`
BindAddr string `mapstructure:"bind_addr"`
}
type DataBaseConfig struct {
// 在 Go 中,只有首字母大写的字段才能被外部包(如 viper访问。
MysqlConn string `mapstructure:"mysql_conn"`
RedisConn string `mapstructure:"redis_conn"`
MysqlUser string `mapstructure:"mysql_user"`
MysqlPass string `mapstructure:"mysql_pass"`
}
type LoggerConfig struct {
// 在 Go 中,只有首字母大写的字段才能被外部包(如 viper访问。
EchoLogFile string `mapstructure:"echo_log_file"`
LogFile string `mapstructure:"log_file"`
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
Compress bool `mapstructure:"compress"`
Level string `mapstructure:"level"`
}
type ConfigManager struct {
BaseConfig AIGrammarBaseConfig
AzureOpenAI AzureOpenAIConfig
OpenAI OpenAIConfig
DBConfig DataBaseConfig
LogConfig LoggerConfig
}
var once sync.Once
var instance *ConfigManager
var initError error
func GetConfigManager() (*ConfigManager, error) {
once.Do(func() {
instance = &ConfigManager{}
initError = instance.initConfig()
})
return instance, initError
}
func (cm *ConfigManager) initConfig() error {
viper.SetConfigName("config")
viper.SetConfigType("toml")
viper.AddConfigPath(".")
if err := viper.ReadInConfig(); err != nil {
return err
}
// 这里如果有字段是带逗号的字符串,处理会有问题,需要改正。
if err := cm.loadConfigSection("base", &cm.BaseConfig); err != nil {
return err
}
if err := cm.loadConfigSection("azure_openai", &cm.AzureOpenAI); err != nil {
return err
}
if err := cm.validateAzureOpenAI(); err != nil {
return err
}
if err := cm.loadConfigSection("openai", &cm.OpenAI); err != nil {
return err
}
if err := cm.loadConfigSection("database", &cm.DBConfig); err != nil {
return err
}
if err := cm.loadConfigSection("log", &cm.LogConfig); err != nil {
return err
}
return nil
}
func (cm *ConfigManager) loadConfigSection(key string, config interface{}) error {
return viper.UnmarshalKey(key, config, viper.DecodeHook(
func(f reflect.Kind, t reflect.Kind, data interface{}) (interface{}, error) {
if f == reflect.String && t == reflect.Slice {
return strings.Split(data.(string), ","), nil
}
return data, nil
}))
}
// validateAzureOpenAI checks that the AzureOpenAI configuration has all required fields properly set.
func (cm *ConfigManager) validateAzureOpenAI() error {
if strings.TrimSpace(cm.AzureOpenAI.Endpoint) == "" {
return errors.New("AzureOpenAI endpoint cannot be empty")
}
if strings.TrimSpace(cm.AzureOpenAI.GPT4Model) == "" {
return errors.New("AzureOpenAI GPT4Model cannot be empty")
}
if strings.TrimSpace(cm.AzureOpenAI.GPT35Model) == "" {
return errors.New("AzureOpenAI GPT35Model cannot be empty")
}
if len(cm.AzureOpenAI.Keys) == 0 || strings.TrimSpace(cm.AzureOpenAI.Keys[0]) == "" {
return errors.New("AzureOpenAI Keys must contain at least one valid key")
}
if strings.TrimSpace(cm.BaseConfig.JwtSecret) == "" {
return errors.New("jwt secret cannot be empty")
}
return nil
}
func (cm *ConfigManager) GetAzureConfig() *AzureOpenAIConfig {
return &cm.AzureOpenAI
}
func (cm *ConfigManager) GetOpenAIConfig() *OpenAIConfig {
return &cm.OpenAI
}
func (cm *ConfigManager) GetBaseConfig() *AIGrammarBaseConfig {
return &cm.BaseConfig
}
func (cm *ConfigManager) GetDatabaseConfig() *DataBaseConfig {
return &cm.DBConfig
}
func (cm *ConfigManager) GetLogConfig() *LoggerConfig {
return &cm.LogConfig
}

27
config.toml Normal file
View File

@ -0,0 +1,27 @@
[base]
jwt_secret = "mCTf-JhNRnhaaGJy_x"
bind_addr = ":80"
[log]
echo_log_file = "logs/echo.log"
log_file = "logs/app.log"
max_size = 500
max_backups = 3
max_age = 28
compress = true
level = "debug"
[azure_openai]
endpoint = "https://grammar.openai.azure.com/"
keys = "8b68c235b737488ab9a99983a14f8cca,0274ccde58aa47b189f0d13349885ad3"
gpt4_model = "gpt4"
gpt35_model = "gpt35"
[database]
mysql_conn = "172.18.0.3:3306"
mysql_user = "root"
mysql_pass = "mysqlpw"
redis_conn = "172.18.0.2:6379"

110
db.go Normal file
View File

@ -0,0 +1,110 @@
package main
import (
"database/sql"
"errors"
"fmt"
"log"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9"
)
type DBManager struct {
MySQL *sql.DB
Redis *redis.Client
}
// dbInstance holds the single instance of DBManager
var dbInstance *DBManager
var oncedb sync.Once
// GetDBManager provides a global access point to the DBManager instance
func GetDBManager() (*DBManager, error) {
oncedb.Do(func() {
config, _ := GetConfigManager()
dbInstance = &DBManager{}
initError = initDBManager(dbInstance, config)
})
return dbInstance, initError
}
// initDBManager initializes the DBManager instance with database connections
func initDBManager(dbManager *DBManager, config *ConfigManager) error {
// 初始化 MySQL 连接
//dsn := "username:password@tcp(localhost:3306)/aigrammar?parseTime=true"
dsn := fmt.Sprintf("%s:%s@tcp(%s)/aigrammar?parseTime=true",
config.GetDatabaseConfig().MysqlUser,
config.GetDatabaseConfig().MysqlPass,
config.GetDatabaseConfig().MysqlConn,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error opening MySQL database: %v", err)
return errors.New("Error opening MySQL database:")
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(time.Minute * 5)
dbManager.MySQL = db
// 初始化 Redis 连接
rdb := redis.NewClient(&redis.Options{
Addr: config.GetDatabaseConfig().RedisConn,
Password: "", // no password set
DB: 0, // use default DB
PoolSize: 10, // 连接池大小
})
dbManager.Redis = rdb
return nil
}
func (db *DBManager) CloseDB() {
db.MySQL.Close()
db.Redis.Close()
}
/*
func NewDBManager(config *ConfigManager) (*DBManager, error) {
dbManager := &DBManager{}
// 初始化 MySQL 连接
//dsn := "username:password@tcp(localhost:3306)/aigrammar?parseTime=true"
dsn := fmt.Sprintf("%s:%s@tcp(%s)/aigrammar?parseTime=true",
config.GetDatabaseConfig().MysqlUser,
config.GetDatabaseConfig().MysqlPass,
config.GetDatabaseConfig().MysqlConn,
)
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error opening MySQL database: %v", err)
return nil, errors.New("Error opening MySQL database: %v", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(time.Minute * 5)
dbManager.MySQL = db
// 初始化 Redis 连接
rdb := redis.NewClient(&redis.Options{
Addr: config.GetDatabaseConfig().RedisConn,
Password: "", // no password set
DB: 0, // use default DB
PoolSize: 10, // 连接池大小
})
dbManager.Redis = rdb
return dbManager, nil
}
func (db *DBManager) CloseDB() {
db.MySQL.Close()
db.Redis.Close()
}
*/

17
errcode.go Normal file
View File

@ -0,0 +1,17 @@
package main
// 定义统一的错误码
const (
// 从jwt中解析出来的字段放到 context 中
ERR_BEGIN = 100000
ERR_COMM_AUTH = 100001
ERR_COMM_PARAM = 100002
ERR_COMM_IVALID_USER = 100003
ERR_COMM_SVR_TIMEOUT = 100004
ERR_COMM_SVR_WRONG = 100005
ERR_BENIFIT_FREE_LIMIT = 101000
ERR_DIRTY_CONTENT = 101001
ERR_GRAMMAR_OK = 102000
)

97
format.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"fmt"
"net/http"
"os"
"github.com/labstack/echo/v4"
)
type Response struct {
Ret int `json:"ret"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
func unifiedResponseHandler(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if err != nil {
// Let the custom HTTP error handler handle the error
fmt.Fprintf(os.Stderr, "print error: %v", err)
return err
}
// Handle successful responses
ret, _ := c.Get("ret").(int)
msg, _ := c.Get("msg").(string)
response := c.Get("response")
return c.JSON(http.StatusOK, Response{
Ret: ret,
Message: msg,
Data: response,
})
}
}
// Custom HTTP error handler
func customHTTPErrorHandler(err error, c echo.Context) {
var (
code = http.StatusInternalServerError
message = "Internal Server Error"
)
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = he.Message.(string)
}
c.JSON(http.StatusOK, Response{
Ret: code,
Message: message,
Data: nil,
})
}
// Helper function to set the response
func setResponse(c echo.Context, data interface{}) {
c.Set("ret", 0)
c.Set("msg", "success")
c.Set("response", data)
}
// Helper function to set the response
func setErrResponse(c echo.Context, ret int, msg string) {
c.Set("ret", ret)
c.Set("msg", msg)
c.Set("response", nil)
}
/*
func unifiedResponseHandler(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if err != nil {
c.Error(err)
}
// Retrieve the response
if he, ok := err.(*echo.HTTPError); ok {
return c.JSON(http.StatusOK, Response{
Ret: he.Code,
Message: he.Message.(string),
Data: nil,
})
}
// Handle successful responses
response := c.Get("response")
return c.JSON(http.StatusOK, Response{
Ret: 0,
Message: "success",
Data: response,
})
}
}
*/

53
go.mod Normal file
View File

@ -0,0 +1,53 @@
module aigrammar
go 1.22.2
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.5.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 // indirect
github.com/awa/go-iap v1.33.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.0.0 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/izniburak/appstore-notifications-go v1.2.0 // indirect
github.com/labstack/echo-jwt/v4 v4.2.0 // indirect
github.com/labstack/echo/v4 v4.12.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/redis/go-redis/v9 v9.5.3 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

113
go.sum Normal file
View File

@ -0,0 +1,113 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.5.1 h1:I/QS4sYByil1QAEkqGDJFpgsjIq9p2GzevLm2j2qhlw=
github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.5.1/go.mod h1:pzGC8ZUnOtOCnyXHTBkj0+BjgFUsnWcqyI3FjvpnQU8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 h1:c4k2FIYIh4xtwqrQwV0Ct1v5+ehlNXj5NI/MWVsiTkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2/go.mod h1:5FDJtLEO/GxwNgUxbwrY3LP0pEoThTQJtk2oysdXHxM=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
github.com/awa/go-iap v1.33.1 h1:MCKQN5FfS7rvJnVOkFh9+xhWJhZI5/OBe8FV6aTewO8=
github.com/awa/go-iap v1.33.1/go.mod h1:roSGnO9xHwxg8BKKnDY2gsjO9XskLZVay6+0+RY59Lg=
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/izniburak/appstore-notifications-go v1.2.0 h1:nudilqBpyu2B3rr8/1R9p5d/ViXgCW05RnY+zAp6YAA=
github.com/izniburak/appstore-notifications-go v1.2.0/go.mod h1:ezo9HwwqhnVrZRthC74aHa8yNS85fiHnOA6bb69ySIg=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU=
github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

178
iap.go Normal file
View File

@ -0,0 +1,178 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"github.com/awa/go-iap/appstore/api"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
// 处理appstore的回调
func IapCallbackHandler(c echo.Context) error {
// 获取请求体
body, err := io.ReadAll(c.Request().Body) // {"signedPayload":"..."}
if err != nil {
logger.Error("Failed to read request body", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, "read body error.")
//return c.JSON(http.StatusInternalServerError, "Failed to read request body")
}
// App Store Server Notification Request JSON String
var request AppStoreServerRequest
err2 := json.Unmarshal([]byte(body), &request) // bind byte to header structure
if err2 != nil {
logger.Error("AppStoreServerRequest Unmarshal error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, "Failed to read request body")
}
// Apple Root CA - G3 Root certificate
// for details: https://www.apple.com/certificateauthority/
// you need download it and covert it to a valid pem file in order to verify X5c certificates
// `openssl x509 -in AppleRootCA-G3.cer -out cert.pem`
appStoreServerNotification, err := IAP_Notify_New(request.SignedPayload, IAP_ROOT_CERT)
if err != nil {
logger.Error("IAP_Notify_New error.", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to decode body")
}
// 打印字段,为了显示方便,把加密串替换掉
appStoreServerNotification.Payload.Data.SignedRenewalInfo = "..."
appStoreServerNotification.Payload.Data.SignedTransactionInfo = "..."
logger.Debug("appStoreServerNotification", zap.Any("appStoreServerNotification", appStoreServerNotification))
// 打印日志
//buff, _ := json.Marshal(&appStoreServerNotification)
//fmt.Println(string(buff))
UpdateOrderByNotify(appStoreServerNotification)
setResponse(c, nil)
return nil
}
// 处理从客户端过来的订单验证请求
func IapVerify(c echo.Context) error {
var request struct {
TransID string `json:"transid" form:"transid"`
AppAccountToken string `json:"appaccounttoken" form:"appaccounttoken"`
Env string `json:"env" form:"env"`
ProductID string `json:"productid" form:"productid"`
ReceiptData string `json:"receiptdata" form:"receiptdata"`
}
if err := c.Bind(&request); err != nil {
logger.Error("read param error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// 验证 receiptdata 是否为有效的 JSON 字符串
var jsonObj interface{}
if err := json.Unmarshal([]byte(request.ReceiptData), &jsonObj); err != nil {
logger.Debug("receiptdata from request", zap.Any("receiptdata", request.ReceiptData)) // 打印日志
} else {
logger.Debug("receiptdata from request", zap.Any("receiptdata", jsonObj)) // 打印日志
}
var isSandBox = true
// 忽略大小写进行比较
if strings.EqualFold(request.Env, "Production") {
isSandBox = false
}
cfg := &api.StoreConfig{
KeyContent: []byte(IAP_ACCOUNT_KEY), // Loads a .p8 certificate
KeyID: IAP_KEY_ID, // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID: IAP_BUNDLEID, // Your apps bundle ID
Issuer: IAP_ISSUER, // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox: isSandBox, // default is Production
}
client := api.NewStoreClient(cfg)
ctx := context.Background()
response, err := client.GetTransactionInfo(ctx, request.TransID)
if err != nil {
logger.Error("GetTransactionInfo error.", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "GetTransactionInfo error")
}
transantion, err := client.ParseSignedTransaction(response.SignedTransactionInfo)
if err != nil {
logger.Error("ParseSignedTransaction error.", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "ParseSignedTransaction error")
}
logger.Debug("transantion", zap.Any("transantion", transantion)) // 打印日志
//buff, _ := json.Marshal(&transantion)
//fmt.Println(string(buff))
if transantion.TransactionID != request.TransID {
logger.Error("transactionId not match.", zap.Any("transantion.TransactionID", transantion.TransactionID), zap.Any("request.TransID", request.TransID))
return echo.NewHTTPError(http.StatusInternalServerError, "transactionId not match")
}
// 写入DB
GID, _ := c.Get(KEY_GID).(int)
errDB := UpdateOrderByVerify(GID, request.AppAccountToken, transantion.OriginalTransactionId, transantion)
if errDB != nil {
logger.Error("UpdateOrderByVerify error.", zap.Error(errDB))
return echo.NewHTTPError(http.StatusInternalServerError, "UpdateOrderByVerify error")
}
setResponse(c, map[string]string{"ret": "ok"})
return nil
}
// 查询订单历史信息,通常是内部服务发起
func IapHistory(c echo.Context) error {
var request struct {
OriginTransID string `json:"origintransid" form:"origintransid"`
Lang string `json:"lang" form:"lang"`
}
if err := c.Bind(&request); err != nil {
logger.Error("read param error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
cfg := &api.StoreConfig{
KeyContent: []byte(IAP_ACCOUNT_KEY), // Loads a .p8 certificate
KeyID: IAP_KEY_ID, // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
BundleID: IAP_BUNDLEID, // Your apps bundle ID
Issuer: IAP_ISSUER, // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
Sandbox: true, // default is Production
}
client := api.NewStoreClient(cfg)
query := &url.Values{}
query.Set("productType", "AUTO_RENEWABLE")
//query.Set("productType", "NON_CONSUMABLE")
ctx := context.Background()
responses, err := client.GetTransactionHistory(ctx, request.OriginTransID, query)
if err != nil {
logger.Error("GetTransactionHistory error.", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "GetTransactionHistory error")
}
// 由于接口字段中有HasMore所以 responses 是个数组,每个 responses 中的 transantions 也是数组
var allTransactions []*api.JWSTransaction
for _, response := range responses {
transantions, err := client.ParseSignedTransactions(response.SignedTransactions)
if err != nil {
logger.Error("ParseSignedTransactions error.", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "ParseSignedTransactions error")
}
allTransactions = append(allTransactions, transantions...)
logger.Debug("transantions", zap.Any("transantions", transantions)) // 打印
//buff, _ := json.Marshal(&transantions)
//fmt.Println(string(buff))
}
setResponse(c, allTransactions)
//setResponse(c, map[string]string{"ret": "ok"})
return nil
}

36
iap_def.go Normal file
View File

@ -0,0 +1,36 @@
package main
// 定义公共变量名
const (
IAP_KEY_ID = "4QU88N92RF"
IAP_BUNDLEID = "com.easyprompts.aigrammar"
IAP_ISSUER = "3f08cd58-bf08-44b3-bb4b-d86ddc9cafe8"
IAP_ROOT_CERT = `
-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
6BgD56KyKA==
-----END CERTIFICATE-----
`
// For generate key file and download it, please refer to https://developer.apple.com/documentation/appstoreserverapi/creating_api_keys_to_use_with_the_app_store_server_api
IAP_ACCOUNT_KEY = `
-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgVB1nZ5afhMVcik5i
aOI3CZgn2LpuRt41Sk4X6CZQftSgCgYIKoZIzj0DAQehRANCAARzgJhXtQ946Spu
soEazCxU/4qMJRSAmFJgdhTnmVrD3SoFv1sjRRtYxY0VX1rsym2wVA2bJVDBONju
wDVRzD84
-----END PRIVATE KEY-----
`
)

182
iap_notify_v2.go Normal file
View File

@ -0,0 +1,182 @@
package main
/*
* 来源于: https://github.com/izniburak/appstore-notifications-go
* 源码中 parseJwtSignedPayload 对出错直接用了 panic不能直接引用
* 修改代码以更健壮。
*
*
*/
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/golang-jwt/jwt"
)
func IAP_Notify_New(payload string, appleRootCert string) (*AppStoreServerNotification, error) {
asn := &AppStoreServerNotification{}
asn.IsValid = false
asn.IsTest = false
asn.appleRootCert = appleRootCert
if err := asn.parseJwtSignedPayload(payload); err != nil {
return nil, err
}
return asn, nil
}
func (asn *AppStoreServerNotification) extractHeaderByIndex(payload string, index int) ([]byte, error) {
// get header from token
payloadArr := strings.Split(payload, ".")
// convert header to byte
headerByte, err := base64.RawStdEncoding.DecodeString(payloadArr[0])
if err != nil {
return nil, err
}
// bind byte to header structure
var header NotificationHeader
err = json.Unmarshal(headerByte, &header)
if err != nil {
return nil, err
}
// decode x.509 certificate headers to byte
certByte, err := base64.StdEncoding.DecodeString(header.X5c[index])
if err != nil {
return nil, err
}
return certByte, nil
}
func (asn *AppStoreServerNotification) verifyCertificate(certByte []byte, intermediateCert []byte) error {
// create certificate pool
roots := x509.NewCertPool()
// parse and append apple root certificate to the pool
ok := roots.AppendCertsFromPEM([]byte(asn.appleRootCert))
if !ok {
return errors.New("root certificate couldn't be parsed")
}
// parse and append intermediate x5c certificate
interCert, err := x509.ParseCertificate(intermediateCert)
if err != nil {
return errors.New("intermediate certificate couldn't be parsed")
}
intermediate := x509.NewCertPool()
intermediate.AddCert(interCert)
// parse x5c certificate
cert, err := x509.ParseCertificate(certByte)
if err != nil {
return err
}
// verify X5c certificate using app store certificate resides in opts
opts := x509.VerifyOptions{
Roots: roots,
Intermediates: intermediate,
}
if _, err := cert.Verify(opts); err != nil {
return err
}
return nil
}
func (asn *AppStoreServerNotification) extractPublicKeyFromPayload(payload string) (*ecdsa.PublicKey, error) {
// get certificate from X5c[0] header
certStr, err := asn.extractHeaderByIndex(payload, 0)
if err != nil {
return nil, err
}
// parse certificate
cert, err := x509.ParseCertificate(certStr)
if err != nil {
return nil, err
}
// get public key
switch pk := cert.PublicKey.(type) {
case *ecdsa.PublicKey:
return pk, nil
default:
return nil, errors.New("appstore public key must be of type ecdsa.PublicKey")
}
}
func (asn *AppStoreServerNotification) parseJwtSignedPayload(payload string) error {
// get root certificate from x5c header
rootCertStr, err := asn.extractHeaderByIndex(payload, 2)
if err != nil {
return errors.New("extractHeaderByIndex error, in rootCertStr")
}
// get intermediate certificate from x5c header
intermediateCertStr, err := asn.extractHeaderByIndex(payload, 1)
if err != nil {
return errors.New("extractHeaderByIndex error, in intermediateCertStr")
}
// verify certificates
if err = asn.verifyCertificate(rootCertStr, intermediateCertStr); err != nil {
fmt.Printf("verifyCertificate eror: %v\n", err)
return errors.New("verifyCertificate eror")
}
// payload data
notificationPayload := &NotificationPayload{}
_, err = jwt.ParseWithClaims(payload, notificationPayload, func(token *jwt.Token) (interface{}, error) {
return asn.extractPublicKeyFromPayload(payload)
})
if err != nil {
return errors.New("ParseWithClaims NotificationPayload error")
}
asn.Payload = notificationPayload
asn.IsTest = asn.Payload.NotificationType == "TEST"
if asn.IsTest {
asn.IsValid = true
return nil
}
// transaction info
transactionInfo := &TransactionInfo{}
payload = asn.Payload.Data.SignedTransactionInfo
_, err = jwt.ParseWithClaims(payload, transactionInfo, func(token *jwt.Token) (interface{}, error) {
return asn.extractPublicKeyFromPayload(payload)
})
if err != nil {
return errors.New("ParseWithClaims SignedTransactionInfo error")
}
asn.TransactionInfo = transactionInfo
// renewal info
renewalInfo := &RenewalInfo{}
payload = asn.Payload.Data.SignedRenewalInfo
_, err = jwt.ParseWithClaims(payload, renewalInfo, func(token *jwt.Token) (interface{}, error) {
return asn.extractPublicKeyFromPayload(payload)
})
if err != nil {
return errors.New("ParseWithClaims SignedRenewalInfo error")
}
asn.RenewalInfo = renewalInfo
// valid request
asn.IsValid = true
return nil
}

118
iap_notify_v2_types.go Normal file
View File

@ -0,0 +1,118 @@
package main
/*
* 来源于: https://github.com/izniburak/appstore-notifications-go
* 源码中 parseJwtSignedPayload 对出错直接用了 panic不能直接引用
* 修改代码以更健壮。
*
*
*/
import "github.com/golang-jwt/jwt"
type AppStoreServerNotification struct {
appleRootCert string
Payload *NotificationPayload
TransactionInfo *TransactionInfo
RenewalInfo *RenewalInfo
IsValid bool
IsTest bool
}
type AppStoreServerRequest struct {
SignedPayload string `json:"signedPayload"`
}
type NotificationHeader struct {
Alg string `json:"alg"`
X5c []string `json:"x5c"`
}
type NotificationPayload struct {
jwt.StandardClaims
NotificationType string `json:"notificationType"`
Subtype string `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
Version string `json:"version"`
SignedDate int `json:"signedDate"`
Summary NotificationSummary `json:"summary,omitempty"`
Data NotificationData `json:"data,omitempty"`
ExternalPurchaseToken ExternalPurchaseToken `json:"externalPurchaseToken,omitempty"`
}
type ExternalPurchaseToken struct {
ExternalPurchaseId string `json:"externalPurchaseId"`
TokenCreationDate int `json:"tokenCreationDate"`
AppAppleId string `json:"appAppleId"`
BundleId string `json:"bundleId"`
}
type NotificationSummary struct {
RequestIdentifier string `json:"requestIdentifier"`
AppAppleId string `json:"appAppleId"`
BundleId string `json:"bundleId"`
ProductId string `json:"productId"`
Environment string `json:"environment"`
StoreFrontCountryCodes []string `json:"storefrontCountryCodes"`
FailedCount int64 `json:"failedCount"`
SucceededCount int64 `json:"succeededCount"`
}
type NotificationData struct {
AppAppleId int `json:"appAppleId"`
BundleId string `json:"bundleId"`
BundleVersion string `json:"bundleVersion"`
Environment string `json:"environment"`
SignedRenewalInfo string `json:"signedRenewalInfo"`
SignedTransactionInfo string `json:"signedTransactionInfo"`
Status int32 `json:"status"`
ConsumptionRequestReason string `json:"consumptionRequestReason,omitempty"`
}
type TransactionInfo struct {
jwt.StandardClaims
AppAccountToken string `json:"appAccountToken"`
BundleId string `json:"bundleId"`
Currency string `json:"currency,omitempty"`
Environment string `json:"environment"`
ExpiresDate int `json:"expiresDate"`
InAppOwnershipType string `json:"inAppOwnershipType"`
IsUpgraded bool `json:"isUpgraded"`
OfferDiscountType string `json:"offerDiscountType"`
OfferIdentifier string `json:"offerIdentifier"`
OfferType int32 `json:"offerType"`
OriginalPurchaseDate int `json:"originalPurchaseDate"`
OriginalTransactionId string `json:"originalTransactionId"`
Price int64 `json:"price,omitempty"`
ProductId string `json:"productId"`
PurchaseDate int `json:"purchaseDate"`
Quantity int32 `json:"quantity"`
RevocationDate int `json:"revocationDate"`
RevocationReason int32 `json:"revocationReason"`
SignedDate int `json:"signedDate"`
StoreFront string `json:"storefront"`
StoreFrontId string `json:"storefrontId"`
SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"`
TransactionId string `json:"transactionId"`
TransactionReason string `json:"transactionReason"`
Type string `json:"type"`
WebOrderLineItemId string `json:"webOrderLineItemId"`
}
type RenewalInfo struct {
jwt.StandardClaims
AutoRenewProductId string `json:"autoRenewProductId"`
AutoRenewStatus int32 `json:"autoRenewStatus"`
Environment string `json:"environment"`
ExpirationIntent int32 `json:"expirationIntent"`
GracePeriodExpiresDate int `json:"gracePeriodExpiresDate"`
IsInBillingRetryPeriod bool `json:"isInBillingRetryPeriod"`
OfferIdentifier string `json:"offerIdentifier"`
OfferType int32 `json:"offerType"`
OriginalTransactionId string `json:"originalTransactionId"`
PriceIncreaseStatus int32 `json:"priceIncreaseStatus"`
ProductId string `json:"productId"`
RecentSubscriptionStartDate int `json:"recentSubscriptionStartDate"`
RenewalDate int `json:"renewalDate"`
SignedDate int `json:"signedDate"`
}

53
logger.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"github.com/natefinch/lumberjack"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Logger 是全局日志器
var logger *zap.Logger
func initLogger(filename string, maxsize int, maxbackups int, maxage int, compress bool, level string) {
// 日志文件切割配置
logWriter := zapcore.AddSync(&lumberjack.Logger{
Filename: filename, // 日志文件位置
MaxSize: maxsize, // 每个日志文件保存的最大尺寸 单位M
MaxBackups: maxbackups, // 日志文件最多保存多少个备份
MaxAge: maxage, // 文件最多保存多少天
Compress: compress, // 是否压缩
})
// 编码器配置
encoderConfig := zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
FunctionKey: "func",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder, // 小写编码器
EncodeTime: zapcore.ISO8601TimeEncoder, // ISO8601 UTC 时间格式
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder, // 短路径编码器
}
// 设置日志级别
// UnmarshalText unmarshals the text to an AtomicLevel. It uses the same text representations as the static
// zapcore.Levels ("debug", "info", "warn", "error", "dpanic", "panic", and "fatal").
atomicLevel := zap.NewAtomicLevel()
atomicLevel.UnmarshalText([]byte(level))
//atomicLevel.SetLevel(zap.InfoLevel)
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig), // 编码器配置
logWriter, // 日志写入器
atomicLevel, // 日志级别
)
// 初始化日志器
logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
}

228
logs/app.log Normal file

File diff suppressed because one or more lines are too long

67
logs/echo.log Normal file
View File

@ -0,0 +1,67 @@
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-01T19:05:10+08:00","uri":"/pub/iap/callback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-01T19:24:23+08:00","uri":"/pub/iap/callback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-01T19:24:38+08:00","uri":"/internal/iap/history","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T16:48:49+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T16:54:57+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":500,"time":"2024-07-02T17:20:21+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T17:24:08+08:00","uri":"/pub/iap/callback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T17:29:08+08:00","uri":"/pub/iap/callback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T17:30:30+08:00","uri":"/pub/iap/callback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-02T17:37:40+08:00","uri":"/grammar/feedback","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":500,"time":"2024-07-02T18:21:48+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":500,"time":"2024-07-02T18:22:29+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-03T11:52:24+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-03T11:53:23+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-03T18:29:58+08:00","uri":"/user/get","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":500,"time":"2024-07-04T08:46:11+08:00","uri":"/user/get","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":500,"time":"2024-07-04T09:03:49+08:00","uri":"/user/get","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T09:05:34+08:00","uri":"/user/get","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T09:12:22+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T09:36:49+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T11:10:46+08:00","uri":"/iap/verify","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:15:31+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:21:29+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:21:33+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:24:02+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:24:02+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:26:46+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:26:48+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:29:29+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:29:31+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:32:41+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:32:43+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:37:03+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:37:04+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:39:05+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:39:05+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:42:24+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:42:25+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:45:15+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:45:16+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:48:36+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:48:37+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:51:16+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:51:18+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:53:33+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:54:18+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:54:19+08:00","uri":"/iap/verify","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T17:57:38+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T19:07:40+08:00","uri":"/grammar/translate","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T19:08:08+08:00","uri":"/grammar/grammar","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-04T19:10:37+08:00","uri":"/grammar/words","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T08:37:44+08:00","uri":"/user/get","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T08:50:41+08:00","uri":"/grammar/grammar","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T08:56:16+08:00","uri":"/grammar/grammar","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T08:58:28+08:00","uri":"/grammar/words","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T08:59:33+08:00","uri":"/grammar/translate","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:03:06+08:00","uri":"/internal/user/rights","user_agent":""}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:05:35+08:00","uri":"/internal/user/rights?ID=10004","user_agent":""}
{"level":"fatal","msg":"Failed to start server{error 26 0 listen tcp :80: bind: address already in use}","time":"2024-07-05T09:32:29+08:00"}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:33:56+08:00","uri":"/internal/user/rights/reset?ID=10004","user_agent":""}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:34:05+08:00","uri":"/internal/user/rights?ID=10004","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:34:14+08:00","uri":"/grammar/translate","user_agent":"AIGrammar/1.0 (com.easyprompts.aigrammar; build:1; iOS 16.7.8) Alamofire/5.9.1"}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-07-05T09:45:11+08:00","uri":"/internal/user/rights?ID=10004","user_agent":""}
{"level":"info","method":"GET","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-08-01T17:49:18+08:00","uri":"/internal/user/rights?ID=10004","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-08-01T17:49:38+08:00","uri":"/grammar/words","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-08-01T18:56:25+08:00","uri":"/grammar/words","user_agent":""}
{"level":"info","method":"POST","msg":"request log","remote_ip":"192.168.65.1","status":200,"time":"2024-08-01T18:56:35+08:00","uri":"/grammar/feedback","user_agent":""}

167
main.go Normal file
View File

@ -0,0 +1,167 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/golang-jwt/jwt"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/natefinch/lumberjack"
"github.com/sirupsen/logrus"
"go.uber.org/zap"
)
// 私有变量只能在main包内访问
var jwtSigningKey []byte
func main() {
configManager, err := GetConfigManager()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1) // Exit the program with an error code
}
logconfig := configManager.GetLogConfig()
initLogger(logconfig.LogFile, logconfig.MaxSize, logconfig.MaxBackups, logconfig.MaxAge, logconfig.Compress, logconfig.Level) // 初始化全局日志
defer logger.Sync() // 刷到磁盘
// 初始化数据库,并创建实例
dbManager, errdb := GetDBManager()
if errdb != nil {
logger.Fatal("DBManager init error", zap.Error(errdb)) // 记录错误信息
os.Exit(1) // Exit the program with an error code
} else {
logger.Info("DBManager init successfully. Mysql ", zap.String("mysql_conn", configManager.GetDatabaseConfig().MysqlConn))
}
defer dbManager.CloseDB()
// JWT密钥写到配置文件中
baseConfig := configManager.GetBaseConfig()
jwtSigningKey = []byte(baseConfig.JwtSecret) // 设置JWT密钥
e := echo.New()
// 处理日志,格式可定义,日志输出到文件,且文件自动分割
logger := logrus.New()
logger.SetFormatter(&logrus.JSONFormatter{})
// Configure Lumberjack for log rotation
logOutput := &lumberjack.Logger{
Filename: logconfig.EchoLogFile,
MaxSize: logconfig.MaxSize, // megabytes
MaxBackups: logconfig.MaxBackups,
MaxAge: logconfig.MaxAge, // days
Compress: logconfig.Compress, // disabled by default
}
// Set output of logger to both stdout and Lumberjack
multiWriter := io.MultiWriter(os.Stdout, logOutput)
logger.SetOutput(multiWriter)
// Custom log format middleware
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogStatus: true,
LogURI: true,
LogMethod: true,
LogRemoteIP: true,
LogUserAgent: true,
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
logger.WithFields(logrus.Fields{
"status": v.Status,
"uri": v.URI,
"method": v.Method,
"remote_ip": c.RealIP(),
"user_agent": v.UserAgent,
}).Info("request log")
return nil
},
}))
// 全局中间件
//e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(unifiedResponseHandler) // Register the unified response handler
e.HTTPErrorHandler = customHTTPErrorHandler
// 定义一组无需jwt鉴权的处理功能
s := e.Group("/pub")
s.GET("/ping", PingHander)
s.POST("/iap/callback", IapCallbackHandler)
// 处理应用商店的回调
p := e.Group("/")
// 使用 echo-jwt 替换 deprecated JWT middleware
p.Use(echojwt.WithConfig(echojwt.Config{
SigningKey: baseConfig.JwtSecret,
ContextKey: "user",
ParseTokenFunc: parseToken,
}))
p.POST("grammar/translate", TranslateHandler)
p.POST("grammar/grammar", GrammarHandler)
p.POST("grammar/words", WordsHandler)
p.POST("grammar/feedback", TranslateFeedBackHandler)
p.POST("iap/verify", IapVerify)
p.POST("user/get", queryUserHandler)
i := e.Group("/internal")
i.POST("/iap/history", IapHistory)
i.GET("/user/rights", UserRightsHandler)
i.GET("/user/rights/reset", ResetUserRightsHandler)
// 启动服务器
logger.Fatal("Failed to start server", zap.Error(e.Start(baseConfig.BindAddr)))
//e.Logger.Fatal(e.Start(baseConfig.BindAddr))
}
// 自定义的JWT Claims结构
type jwtCustomClaims struct {
DeviceID string `json:"deviceID"`
GID int `json:"gid"`
Exp1 int64 `json:"exp1"`
jwt.StandardClaims
}
func parseToken(c echo.Context, auth string) (interface{}, error) {
logger.Debug("into func")
token, err := jwt.ParseWithClaims(auth, &jwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSigningKey, nil
})
if err != nil {
logger.Fatal("ParaseToken Error.", zap.Error(err))
return nil, err
}
if claims, ok := token.Claims.(*jwtCustomClaims); ok && token.Valid {
logger.Info("claims: ", zap.Any("claims", claims))
// 判断token有效期
if time.Now().Unix() > claims.Exp1 {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "Token expired")
}
// 设置参数
c.Set(KEY_DEVICEID, claims.DeviceID)
c.Set(KEY_GID, claims.GID)
return token, nil
}
return nil, fmt.Errorf("invalid token")
}
// 处理翻译请求
func PingHander(c echo.Context) error {
setResponse(c, map[string]string{"pang": "ok"})
return nil
}

25
prompts.go Normal file
View File

@ -0,0 +1,25 @@
package main
// 定义几个不可修改的长字符串常量
const (
// ExamplePrompt1 包含逗号和句号
TranslatePromptTemplate = `You are a professional translator. You need to translate the text the user provided into %s `
// ExamplePrompt2 包含换行符和特殊格式
GrammarPromptTemplate = `You are a language expert. I will send you a piece of English text, which may contain incorrect grammar or misspelled words.
You need to mark the errors, give the reasons for the errors, and provide suggestions for revisions.
Please output the result in the form of an array, in the following format:
[{"plain":"%s", "type":"%s", "reason":"%s", correction":["%s", "%s"]}, {"plain":"%s", "type":"%s", "reason":"%s", "correction":["%s", "%s"]}, ]
The value corresponding to "plain" is a fragment of the input English text,
the value corresponding to "type" is one of "ok", "grammar", and "spell", which respectively correspond to correct, grammatical error, and spelling error;
the value corresponding to "reason" is a description of the modification opinion, which should be kept short within 10 words;
the value corresponding to "correction" is an array, which is filled with our suggestions for the above errors and modifications. There can be multiple suggestions, so we use an array to represent them. Please do not exceed 5.
If there are no errors in the text, please just type the word OK. Please do not add any explanations. `
//If you understand my requirements, please answer OK without explanation.`
// ExamplePrompt3 是另一个复杂的例子
WordsPromptTemplate = `You are an English learning expert. I will give you some words. Please explain the meaning of the word in English, then give common phrases about it, and finally output its synonyms.
Please note that each item does not need to be more than 5.
The result we need is in json format, and the style is: {"word":"$word", "explain":["$exp1", "exp2", ...], "phrase": ["$p1", "$p2", "p3", ...], "sync": ["$s1", "s2", ...]}
Please just output the result according to the format, no explanation is needed.`
)

93
shell/sql Normal file
View File

@ -0,0 +1,93 @@
CREATE TABLE aigrammar.`user` (
ID INT UNSIGNED DEFAULT 10000 auto_increment NOT NULL,
UserID varchar(100) NULL COMMENT 'UserID',
UserName varchar(100) NULL COMMENT 'username',
DeviceID varchar(256) NULL COMMENT 'DeviceID',
RegChannel varchar(100) NULL COMMENT 'Email, Apple, Google',
OpenID varchar(100) NULL COMMENT 'ID from other channels',
RegTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL,
UpdateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT user_pk PRIMARY KEY (ID)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
AUTO_INCREMENT 10000
COLLATE=utf8mb4_0900_ai_ci;
ALTER TABLE aigrammar.`user` MODIFY COLUMN UserID varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'UserID';
ALTER TABLE aigrammar.`user` MODIFY COLUMN UserName varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'username';
ALTER TABLE aigrammar.`user` MODIFY COLUMN RegChannel varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Email, Apple, Google';
ALTER TABLE aigrammar.`user` MODIFY COLUMN OpenID varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'ID from other channels';
ALTER TABLE aigrammar.`user` MODIFY COLUMN DeviceID varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'DeviceID';
CREATE TABLE aigrammar.vip (
ID INT UNSIGNED NOT NULL,
IsVIP INT DEFAULT 0 NULL COMMENT '1-VIP; 0-not vip',
AppStore varchar(100) DEFAULT 'apple' NULL COMMENT 'apple;google',
ProductID varchar(100) NULL,
ProductType varchar(100) NULL COMMENT 'yearly;monthly;weekly;',
Environment varchar(100) NULL COMMENT 'prod;sandbox',
PurchaseDate TIMESTAMP NULL,
Price INT NULL,
Currency varchar(100) NULL,
Storefront varchar(100) NULL COMMENT 'USA',
ExpDate TIMESTAMP NULL,
AutoRenew INT NULL COMMENT '1-yes;0-no',
OriginalTransactionID varchar(100) NULL COMMENT 'applestore originalTransactionId',
CONSTRAINT vip_pk PRIMARY KEY (ID)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE aigrammar.order_log (
LogID INT UNSIGNED auto_increment NOT NULL,
AppStore varchar(100) NULL COMMENT 'apple;google',
NotificationType varchar(100) NULL,
Subtype varchar(100) NULL,
Environment varchar(100) NULL COMMENT 'product;sandbox',
AppAccountToken varchar(100) NULL,
CreateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL,
TransactionInfo TEXT NULL,
RenewalInfo TEXT NULL,
Payload TEXT NULL,
CONSTRAINT oder_log_pk PRIMARY KEY (LogID)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE aigrammar.product (
ProductID varchar(100) NOT NULL,
AppStore varchar(100) DEFAULT 'apple' NOT NULL,
Duration INT DEFAULT 0 NULL COMMENT '订阅天数',
ProductName varchar(100) NULL COMMENT '自定义商品名称',
Price INT NULL COMMENT '定价,分',
Currency varchar(100) NULL COMMENT '币种',
CONSTRAINT product_pk PRIMARY KEY (ProductID,AppStore)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE aigrammar.feedback_log (
LogID INT UNSIGNED auto_increment NOT NULL,
AppStore varchar(100) NULL,
Product varchar(100) NULL,
`Input` TEXT NULL,
`Output` TEXT NULL,
`Result` varchar(100) NULL,
UserID INT UNSIGNED NULL,
CreateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL,
CONSTRAINT feedback_log_pk PRIMARY KEY (LogID)
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci;

271
translate.go Normal file
View File

@ -0,0 +1,271 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
// 处理翻译请求
func TranslateHandler(c echo.Context) error {
// 验证参数
var request struct {
Input string `json:"input" form:"input"`
Lang string `json:"lang" form:"lang"`
}
if err := c.Bind(&request); err != nil {
logger.Error("bind request error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if request.Lang == "" {
request.Lang = "Chinese" // 默认语言为中文
}
prompt := fmt.Sprintf(TranslatePromptTemplate, request.Lang)
GID, _ := c.Get(KEY_GID).(int)
// 检查是否有权限
if can, _ := queryUserBenefits(c); !can {
logger.Error("user beyond limit.", zap.Int("ID", GID), zap.String("input", request.Input))
setErrResponse(c, ERR_BENIFIT_FREE_LIMIT, "no benifits left.")
return nil
}
translation, err, errcode := gTranslate(request.Input, prompt)
if err != nil {
logger.Error("query error.", zap.Int("ID", GID), zap.Error(err))
setErrResponse(c, errcode, "server timeout. please try again.")
return nil
}
logger.Info("translation", zap.Int("ID", GID), zap.String("input", request.Input), zap.String("output", translation))
// 返回结果
setResponse(c, map[string]string{"translation": translation})
return nil
}
// 处理语法改错请求
func GrammarHandler(c echo.Context) error {
var request struct {
Input string `json:"input" form:"input"`
Lang string `json:"lang" form:"lang"`
}
if err := c.Bind(&request); err != nil {
logger.Error("bind request error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
GID, _ := c.Get(KEY_GID).(int)
// 检查是否有权限
if can, _ := queryUserBenefits(c); !can {
logger.Error("user beyond limit.", zap.Int("ID", GID), zap.String("input", request.Input))
setErrResponse(c, ERR_BENIFIT_FREE_LIMIT, "no benifits left.")
return nil
}
translation, err, errcode := gTranslate(request.Input, GrammarPromptTemplate)
if err != nil {
logger.Error("query error.", zap.Int("ID", GID), zap.Error(err))
setErrResponse(c, errcode, "server timeout. please try again.")
return nil
}
// 判断 translation 是否为 "OK"
if strings.EqualFold(translation, "OK") {
logger.Info("grammar ok", zap.Int("ID", GID), zap.String("input", request.Input), zap.Any("output", translation))
setErrResponse(c, ERR_GRAMMAR_OK, "grammar ok")
return nil
}
// 验证 translation 是否为有效的 JSON 字符串
var jsonObj interface{}
if err := json.Unmarshal([]byte(translation), &jsonObj); err != nil {
// 记录日志
logger.Error("not json format", zap.Int("ID", GID), zap.String("input", request.Input), zap.String("output", translation))
return echo.NewHTTPError(http.StatusInternalServerError, "Translation is not a valid JSON string: "+err.Error())
}
// 由于 translation 已经是 JSON 字符串,直接以原样返回
logger.Info("grammar", zap.Int("ID", GID), zap.String("input", request.Input), zap.Any("output", jsonObj))
setResponse(c, jsonObj)
return nil
//return c.JSONBlob(http.StatusOK, []byte(translation))
}
// 处理单词解释请求
func WordsHandler(c echo.Context) error {
var request struct {
Input string `json:"input" form:"input"`
Lang string `json:"lang" form:"lang"`
}
if err := c.Bind(&request); err != nil {
logger.Error("bind request error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
GID, _ := c.Get(KEY_GID).(int)
// 检查是否有权限
if can, _ := queryUserBenefits(c); !can {
logger.Error("user beyond limit.", zap.Int("ID", GID), zap.String("input", request.Input))
setErrResponse(c, ERR_BENIFIT_FREE_LIMIT, "no benifits left.")
return nil
}
translation, err, errcode := gTranslate(request.Input, WordsPromptTemplate)
if err != nil {
logger.Error("query error.", zap.Int("ID", GID), zap.Error(err))
setErrResponse(c, errcode, "server timeout. please try again.")
return nil
}
// 验证 translation 是否为有效的 JSON 字符串
var jsonObj interface{}
if err := json.Unmarshal([]byte(translation), &jsonObj); err != nil {
// 记录日志
logger.Error("not json format", zap.Int("ID", GID), zap.String("input", request.Input), zap.String("output", translation))
return echo.NewHTTPError(http.StatusInternalServerError, "Translation is not a valid JSON string: "+err.Error())
}
logger.Info("words", zap.Int("ID", GID), zap.String("input", request.Input), zap.Any("output", jsonObj))
// 由于 translation 已经是 JSON 字符串,直接以原样返回
setResponse(c, jsonObj)
return nil
//return c.JSONBlob(http.StatusOK, []byte(translation))
}
// 处理用户的反馈
func TranslateFeedBackHandler(c echo.Context) error {
var request struct {
Product string `json:"product" form:"product"`
Input string `json:"input" form:"input"`
Output string `json:"output" form:"output"`
Result string `json:"res" form:"res"`
}
if err := c.Bind(&request); err != nil {
logger.Error("bind request error.", zap.Error(err))
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
GID, _ := c.Get(KEY_GID).(int)
logger.Info("feedback", zap.Int("ID", GID), zap.Any("feedback", request))
// TODO: 写入到 feedback_log 表中
setResponse(c, nil)
return nil
}
// gTranslate 调用Azure OpenAI的翻译接口
func gTranslate(input string, prompt string) (string, error, int) {
// get azure openai config
configManager, err := GetConfigManager()
if err != nil {
logger.Error("GetConfigManager error.", zap.Error(err))
return "", errors.New("Get Config error."), ERR_COMM_SVR_WRONG
}
azureConfig := configManager.GetAzureConfig()
azureOpenAIKey := azureConfig.Keys[0]
modelDeploymentID := azureConfig.GPT4Model
azureOpenAIEndpoint := azureConfig.Endpoint
// API密钥认证
cred := azcore.NewKeyCredential(azureOpenAIKey)
// 创建客户端
client, err := azopenai.NewClientWithKeyCredential(azureOpenAIEndpoint, cred, nil)
if err != nil {
logger.Error("NewClientWithKeyCredential error", zap.Error(err))
return "", err, ERR_COMM_SVR_WRONG
}
// This is a conversation in progress.
// NOTE: all messages, regardless of role, count against token usage for this API.
messages := []azopenai.ChatRequestMessageClassification{
// You set the tone and rules of the conversation with a prompt as the system role.
&azopenai.ChatRequestSystemMessage{Content: to.Ptr(prompt)},
// The user asks a question
//&azopenai.ChatRequestUserMessage{Content: azopenai.NewChatRequestUserMessageContent("Can you help me?")},
// The reply would come back from the ChatGPT. You'd add it to the conversation so we can maintain context.
//&azopenai.ChatRequestAssistantMessage{Content: to.Ptr("the user's text is shown below.")},
// The user answers the question based on the latest reply.
&azopenai.ChatRequestUserMessage{Content: azopenai.NewChatRequestUserMessageContent(input)},
// from here you'd keep iterating, sending responses back from ChatGPT
}
gotReply := false
var resultTextBuilder strings.Builder
resp, err := client.GetChatCompletions(context.TODO(), azopenai.ChatCompletionsOptions{
// This is a conversation in progress.
// NOTE: all messages count against token usage for this API.
Messages: messages,
DeploymentName: &modelDeploymentID,
}, nil)
if err != nil {
logger.Error("GetChatCompletions error", zap.String("input", input), zap.Error(err))
return "", err, ERR_COMM_SVR_WRONG
}
for _, choice := range resp.Choices {
gotReply = true
// 被过滤了,需要做个判断
if choice.ContentFilterResults != nil {
var filter_err = errors.New("no content")
if choice.ContentFilterResults.Error != nil {
filter_err = choice.ContentFilterResults.Error
//fmt.Fprintf(os.Stderr, " Error:%v\n", choice.ContentFilterResults.Error)
}
if *choice.ContentFilterResults.Sexual.Filtered || *choice.ContentFilterResults.Violence.Filtered {
filter_err = errors.New("Sexual or Violence input")
return "", filter_err, ERR_DIRTY_CONTENT
}
logger.Warn("filterd", zap.Any("Hate", *choice.ContentFilterResults.Hate.Severity), zap.Any("Hate-filtered", *choice.ContentFilterResults.Hate.Filtered),
zap.Any("SelfHarm", *choice.ContentFilterResults.SelfHarm.Severity), zap.Any("SelfHarm-filtered", *choice.ContentFilterResults.Hate.Filtered),
zap.Any("Sexual", *choice.ContentFilterResults.Sexual.Severity), zap.Any("Sexual-filtered", *choice.ContentFilterResults.Sexual.Filtered),
zap.Any("Violence", *choice.ContentFilterResults.Violence.Severity), zap.Any("Violence-filtered", *choice.ContentFilterResults.Violence.Filtered),
zap.Any("Error", filter_err), zap.String("input", input))
//fmt.Fprintf(os.Stderr, " Hate: sev: %v, filtered: %v\n", *choice.ContentFilterResults.Hate.Severity, *choice.ContentFilterResults.Hate.Filtered)
//fmt.Fprintf(os.Stderr, " SelfHarm: sev: %v, filtered: %v\n", *choice.ContentFilterResults.SelfHarm.Severity, *choice.ContentFilterResults.SelfHarm.Filtered)
//fmt.Fprintf(os.Stderr, " Sexual: sev: %v, filtered: %v\n", *choice.ContentFilterResults.Sexual.Severity, *choice.ContentFilterResults.Sexual.Filtered)
//fmt.Fprintf(os.Stderr, " Violence: sev: %v, filtered: %v\n", *choice.ContentFilterResults.Violence.Severity, *choice.ContentFilterResults.Violence.Filtered)
}
if choice.Message != nil && choice.Message.Content != nil {
//fmt.Fprintf(os.Stderr, "Content[%d]: %s\n", *choice.Index, *choice.Message.Content)
resultTextBuilder.WriteString(*choice.Message.Content)
}
if choice.FinishReason != nil {
// this choice's conversation is complete.
logger.Debug("Finish Reason", zap.Any("index", *choice.Index), zap.Any("reason", *choice.FinishReason))
//fmt.Fprintf(os.Stderr, "Finish reason[%d]: %s\n", *choice.Index, *choice.FinishReason)
}
}
if !gotReply {
return "", errors.New("Got chat completions reply"), ERR_COMM_SVR_WRONG
}
return resultTextBuilder.String(), nil, 0
}

263
user.go Normal file
View File

@ -0,0 +1,263 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/awa/go-iap/appstore/api"
_ "github.com/go-sql-driver/mysql"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)
type UserResponse struct {
ID int `json:"id"`
UserID string `json:"userid"`
UserName string `json:"username"`
VIP int `json:"vip"`
}
// TODO: 以后引入 GORM mysql driver ,简化数据库操作。
func queryUserHandler(c echo.Context) error {
// 从 context 中获取变量
deviceID := c.Get(KEY_DEVICEID).(string)
GID, _ := c.Get(KEY_GID).(int)
db, _ := GetDBManager()
var response UserResponse
// 查询 user 表
err := db.MySQL.QueryRow("SELECT ID, UserID, UserName FROM user WHERE DeviceID = ?", deviceID).Scan(&response.ID, &response.UserID, &response.UserName)
if err == sql.ErrNoRows {
// 用户不存在,创建新用户
// TODO: 这里要不要自动分配userid这个userid在内部基本不会用到
res, err := db.MySQL.Exec("INSERT INTO user (DeviceID) VALUES (?)", deviceID)
if err != nil {
logger.Error("insert db error", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user")
}
lastID, err := res.LastInsertId()
if err != nil {
logger.Error("insert db error", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve last insert ID")
}
response.ID = int(lastID)
response.UserName = ""
logger.Debug("insert user", zap.Int("ID", response.ID), zap.String("DeviceID", deviceID))
} else if err != nil {
logger.Error("query db error", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
} else {
// 看上传的 userID 跟数据库的是否一致 userID 转int ..
if response.ID != GID {
logger.Warn("userid not match", zap.Int("ID", response.ID), zap.Int("userGID", GID))
//log.Printf("userid not match: %v != %v", numid, response.ID)
}
}
// 查询 vip 表
err = db.MySQL.QueryRow("SELECT IsVIP FROM vip WHERE ID = ?", response.ID).Scan(&response.VIP)
if err == sql.ErrNoRows {
response.VIP = 0 // 默认非VIP
} else if err != nil {
logger.Error("query db error", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
setResponse(c, response)
return nil
//return c.JSON(http.StatusOK, response)
}
// 查询用户的所有redis key内部接口
func UserRightsHandler(c echo.Context) error {
// 获取 c 的 GET方法的参数
ID, _ := strconv.Atoi(c.QueryParam("ID"))
db, _ := GetDBManager()
ub := NewUserBenefits(db.Redis)
// 查询redis
userData, err := ub.QueryUserBenefits(ID)
if err != nil {
logger.Error("QueryUserBenefits", zap.Error(err), zap.Int("ID", ID))
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
} else {
logger.Debug("QueryUserBenefits", zap.Any("userData", userData), zap.Int("ID", ID))
}
setResponse(c, userData)
return nil
}
// 查询用户的所有redis key内部接口
func ResetUserRightsHandler(c echo.Context) error {
// 获取 c 的 GET方法的参数
ID, _ := strconv.Atoi(c.QueryParam("ID"))
datastr := c.QueryParam("datestr")
db, _ := GetDBManager()
ub := NewUserBenefits(db.Redis)
// 查询redis
err := ub.ResetUserBenefits(ID, datastr)
if err != nil {
logger.Error("ResetUserRightsHandler", zap.Error(err), zap.Int("ID", ID))
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
} else {
logger.Debug("ResetUserRightsHandler", zap.Int("ID", ID))
}
setResponse(c, nil)
return nil
}
// 编写查询用户是否VIP的函数输入是GID输出是vip并且给出是否有error ,不需要使用 echo context
func queryUserVIP(ID int) (int, error) {
db, _ := GetDBManager()
var vip int
err := db.MySQL.QueryRow("SELECT IsVIP FROM vip WHERE ID = ?", ID).Scan(&vip)
if err == sql.ErrNoRows {
return 0, nil // 默认非VIP
} else if err != nil {
logger.Error("query db error", zap.Error(err))
return 0, err
}
return vip, nil
}
// 编写查询用户是否有权限使用某功能输入是用户ID如果用户是VIP则有权限否则查询 QueryUserBenefits 看是否超过免费限制,输出 是否有权限,以及是否出错。
func queryUserBenefits(c echo.Context) (bool, error) {
// 获取参数
ID, _ := c.Get(KEY_GID).(int)
timeZone := c.Request().Header.Get(KEY_HEADER_TIMEZONE)
secondsFromGMT, _ := strconv.Atoi(c.Request().Header.Get(KEY_HEADER_SECONDSFROMGMT))
db, _ := GetDBManager()
var vip int
err := db.MySQL.QueryRow("SELECT IsVIP FROM vip WHERE ID = ?", ID).Scan(&vip)
if err == sql.ErrNoRows {
// 非VIP查询redis的免费次数
db, _ := GetDBManager()
ub := NewUserBenefits(db.Redis)
status, err := ub.CheckAndDecrement(ID, timeZone, secondsFromGMT)
if err != nil {
logger.Error("CheckAndDecrement", zap.Error(err), zap.Int("ID", ID), zap.String("timeZone", timeZone), zap.Int("secondsFromGMT", secondsFromGMT))
return false, err
} else {
logger.Debug("CheckAndDecrement", zap.Int("ID", ID), zap.String("timeZone", timeZone), zap.Int("secondsFromGMT", secondsFromGMT), zap.Int("status", status))
return status == 0, nil
}
} else if err != nil {
logger.Error("query db error", zap.Error(err))
return false, err
}
if vip == 1 {
return true, nil
}
return false, nil
}
// 从苹果校验订单后插入vip表中
func UpdateOrderByVerify(ID int, AppAcountToken string, OriginTransID string, transantion *api.JWSTransaction) error {
// 写入vip表如果ID对应记录不存在则插入否则更新
db, _ := GetDBManager()
var ProductType, Currency string
var Price, Duration int
// 先从 product 表中,根据 transantion.ProductID 获取到对应的 DurationProductName Price Currency
err := db.MySQL.QueryRow("SELECT ProductType, Price, Currency, Duration from product where ProductID = ?", transantion.ProductID).Scan(&ProductType, &Price, &Currency, &Duration)
if err == sql.ErrNoRows {
logger.Error("query productID empty.", zap.Error(err), zap.Int("ID", ID), zap.String("AppAcountToken", AppAcountToken), zap.String("OriginTransID", OriginTransID))
return err
} else if err != nil {
logger.Error("query productID error", zap.Error(err), zap.Int("ID", ID), zap.String("AppAcountToken", AppAcountToken), zap.String("OriginTransID", OriginTransID))
return err
}
// 取当前的时间戳
//purchase_time := time.Now().Unix()
//exp_time := purchase_time + int64(Duration)*3600*24
currentTime := time.Now()
nextDay := time.Now().AddDate(0, 0, Duration)
// TODO: transaction.TransactionReason 有新购和续费,需要区分;同一个购买或者续费事件,可能有通知多次,需要排重
var tmpID int
errDup := db.MySQL.QueryRow("SELECT ID from vip where TransactionID = ? and OriginalTransactionID = ? and IsVip = 1 and ExpDate > ?", transantion.TransactionID, transantion.OriginalTransactionId, currentTime).Scan(&tmpID)
if errDup != sql.ErrNoRows {
// 表示重复了,可以直接返回
logger.Info("duplicate request", zap.Int("ID", ID), zap.String("AppAcountToken", AppAcountToken), zap.String("OriginTransID", OriginTransID), zap.String("TransactionID", transantion.TransactionID))
return nil
} else if errDup != nil {
logger.Error("query error", zap.Error(errDup), zap.Int("ID", ID), zap.String("AppAcountToken", AppAcountToken), zap.String("OriginTransID", OriginTransID))
// 这里不返回,继续尝试更新。
}
// 更新到DB
sql := `INSERT INTO vip (ID, IsVip, AppStore, ProductID, ProductType, Environment, Price, Currency, Storefront, PurchaseDate, ExpDate, AutoRenew, OriginalTransactionID, TransactionID, AppAccountToken, TransactionReason)
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
IsVip = 1, AppStore = ?, ProductID = ?, ProductType = ?, Environment = ?, Price = ?, Currency = ?, Storefront = ?, PurchaseDate = ?, ExpDate = ?, AutoRenew = ?, OriginalTransactionID = ? , TransactionID = ?, AppAccountToken = ?, TransactionReason = ? `
_, err2 := db.MySQL.Exec(sql,
ID, APPSTORE, transantion.ProductID, ProductType, transantion.Environment, Price, Currency, transantion.Storefront, currentTime, nextDay, 1, OriginTransID, transantion.TransactionID, transantion.AppAccountToken, transantion.TransactionReason,
APPSTORE, transantion.ProductID, ProductType, transantion.Environment, Price, Currency, transantion.Storefront, currentTime, nextDay, 1, OriginTransID, transantion.TransactionID, transantion.AppAccountToken, transantion.TransactionReason)
if err2 != nil {
logger.Error("UpdateOrderByVerify", zap.Error(err), zap.Int("ID", ID), zap.String("AppAcountToken", AppAcountToken), zap.String("OriginTransID", OriginTransID))
return err2
}
return nil
}
// 接收到appstore的回调写入数据。因为不知道对应的用户账号所以只能记录。
func UpdateOrderByNotify(Notification *AppStoreServerNotification) error {
db, _ := GetDBManager()
// 先写order_log表
sql := `INSERT INTO order_log (AppStore, NotificationType, Subtype, Environment, AppAccountToken, TransactionInfo, RenewalInfo, Payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) `
// 需要把 Notification.TransactionInfo 转成 字符串
TransactionInfo, _ := json.Marshal(Notification.TransactionInfo)
RenewalInfo, _ := json.Marshal(Notification.RenewalInfo)
Payload, _ := json.Marshal(Notification.Payload)
_, err := db.MySQL.Exec(sql,
APPSTORE, Notification.Payload.NotificationType, Notification.Payload.Subtype, Notification.Payload.Data.Environment, Notification.TransactionInfo.AppAccountToken, TransactionInfo, RenewalInfo, Payload)
if err != nil {
logger.Error("UpdateOrderByNotify", zap.Error(err))
return err
}
return nil
}
// 给定 appaccounttoken在order_log中查询是否已经存在了如果存在则无需向苹果发起验证请求
func CheckOrderByAppAcountToken(AppAccountToken string) (bool, error) {
db, _ := GetDBManager()
// 根据AppAccountToken查询 order_log表查看记录是否存在如果存在返回true否则false
var LogID int
err := db.MySQL.QueryRow("SELECT LogID from order_log where AppAccountToken = ? and AppStore = ? ", AppAccountToken, APPSTORE).Scan(&LogID)
if err == sql.ErrNoRows {
logger.Info("query empty", zap.String("AppAccountToken", AppAccountToken))
return false, nil
} else if err != nil {
logger.Error("query error.", zap.Error(err), zap.String("AppAccountToken", AppAccountToken))
return false, err
}
// TODO: 可以在这里更新 vip 表,这样 Verify 的过程中如果查询到记录就不需要再去appstore校验了。
return true, nil
}

139
userBenefits.go Normal file
View File

@ -0,0 +1,139 @@
package main
import (
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/net/context"
)
var ctx = context.Background()
type UserBenefits struct {
client *redis.Client
}
// NewUserBenefits 初始化 UserBenefits 实例
func NewUserBenefits(redisClient *redis.Client) *UserBenefits {
return &UserBenefits{
client: redisClient,
}
}
// CheckAndDecrement 检查用户是否可以访问并递减剩余次数0表示可用其他值表示异常
func (ub *UserBenefits) CheckAndDecrement(ID int, timeZone string, secondsFromGMT int) (int, error) {
loc, err := time.LoadLocation(timeZone)
if err != nil {
// 使用 secondsFromGMT 计算时区
loc = time.FixedZone("Custom", secondsFromGMT)
}
currentTime := time.Now().In(loc)
dateKey := currentTime.Format("20060102")
key := fmt.Sprintf("%d_%s", ID, dateKey)
// 获取当前计数
val, err := ub.client.Get(ctx, key).Int()
if err == redis.Nil {
// 如果没有设置初始化为3
ub.client.Set(ctx, key, DAILY_FREE_COUNT, 24*time.Hour)
val = DAILY_FREE_COUNT
} else if err != nil {
return -1, err
}
// 检查次数是否已用完
if val <= 0 {
return 1, nil
}
// 递减次数
ub.client.Decr(ctx, key)
return 0, nil
}
// QueryUserBenefits 查询用户使用情况
func (ub *UserBenefits) QueryUserBenefits(ID int) (string, error) {
keys, err := ub.client.Keys(ctx, fmt.Sprintf("%d_*", ID)).Result()
if err != nil {
return "", err
}
result := make(map[string]int)
for _, key := range keys {
val, err := ub.client.Get(ctx, key).Int()
if err != nil {
return "", err
}
result[key] = val
}
// 将结果编码为 JSON 格式
jsonData, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonData), nil
}
// ResetUserBenefits 删除指定日期或所有日期的用户权益,只限于内部调用
func (ub *UserBenefits) ResetUserBenefits(ID int, dateStr string) error {
var pattern string
if dateStr == "" {
// 删除该用户的所有权益
pattern = fmt.Sprintf("%d_*", ID)
} else {
// 删除特定日期的权益
pattern = fmt.Sprintf("%d_%s", ID, dateStr)
}
keys, err := ub.client.Keys(ctx, pattern).Result()
if err != nil {
return err
}
if len(keys) == 0 {
fmt.Println("No keys to delete.")
return nil
}
_, err = ub.client.Del(ctx, keys...).Result()
if err != nil {
return err
}
fmt.Printf("Deleted keys for pattern %s\n", pattern)
return nil
}
func test_smain() {
// Redis 客户端使用连接池初始化
rdb := redis.NewClient(&redis.Options{
Addr: "172.18.0.4:6379",
Password: "", // no password set
DB: 0, // use default DB
PoolSize: 10, // 连接池大小
})
ub := NewUserBenefits(rdb)
// 示例使用
status, err := ub.CheckAndDecrement(10001, "Europe/Berlin", 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Status Code:", status)
}
// 查询用户使用情况
userData, err := ub.QueryUserBenefits(10001)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("User Data:", userData)
}
}