import from internal gitlab

This commit is contained in:
nick.sun 2020-04-13 15:22:33 +08:00
parent c6c9fcaf41
commit 4cbf048e0e
4264 changed files with 1833330 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
dit/cmd/ceh-cs-portal/__debug_bin
web/**/node_modules
dump.rdb
__debug_bin
data/
testdata.sqlite
*.sqlite
dit/cmd/coupon-service/coupon/debug.test
*.a

4
bin/build.cmd Normal file
View File

@ -0,0 +1,4 @@
cd ..\src\loreal.com\dit\cmd\coupon-service
make windows
cd ..\..\..\..\..\bin

5
bin/build.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd ../src/loreal.com/dit/cmd/coupon-service
make linux
cd ../../../../../bin

4
bin/test.cmd Normal file
View File

@ -0,0 +1,4 @@
cd ..\src\loreal.com\dit\cmd\coupon-service
make test
cd ..\..\..\..\..\bin

5
bin/test.sh Normal file
View File

@ -0,0 +1,5 @@
#!/bin/bash
cd ../src/loreal.com/dit/cmd/coupon-service
make test
cd ../../../../../bin

8
src/loreal.com/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
dit/cmd/ceh-cs-portal/__debug_bin
web/**/node_modules
dump.rdb
__debug_bin
data/
testdata.sqlite
*.sqlite
dit/cmd/coupon-service/coupon/debug.test

24
src/loreal.com/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch ces",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "C:\\Users\\larry.yu2\\go\\src\\loreal.com\\dit\\cmd\\ceh-cs-portal\\main.go"
},
{
"name": "Debug CCS",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}\\dit\\cmd\\coupon-service\\main.go"
}
]
}

View File

@ -0,0 +1,10 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

39
src/loreal.com/dit/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
################################################
############### .gitignore ##################
################################################
# Common files generated by Node, NPM, and the
# related ecosystem.
################################################
*.seed
*.log
*.out
*.pid
################################################
# Miscellaneous
#
# Common files generated by text editors,
# operating systems, file systems, etc.
################################################
*~
*#
.idea
*.db
*.exe
config/*.json
.vscode/*
.vscode\\*
.vscode\\launch.json
.vscode/launch.json
.DS_Store
out/
debug
.debug/
src/
.DS_Store
*.code-workspace
**/config/*.json

View File

@ -0,0 +1,180 @@
package main
import (
"database/sql"
"loreal.com/dit/module"
"loreal.com/dit/module/modules/root"
"loreal.com/dit/endpoint"
"loreal.com/dit/middlewares"
"loreal.com/dit/utils"
"loreal.com/dit/utils/task"
"log"
"net/http"
"strings"
"sync"
"github.com/robfig/cron"
)
//App - data struct for App & configuration file
type App struct {
Name string
Description string
Config *Config
Root *root.Module
Endpoints map[string]EndpointEntry
MessageHandlers map[string]func(*module.Message) bool
AuthProvider middlewares.RoleVerifier
Scheduler *cron.Cron
TaskManager *task.Manager
wg *sync.WaitGroup
mutex *sync.RWMutex
Runtime map[string]*RuntimeEnv
}
//RuntimeEnv - runtime env
type RuntimeEnv struct {
Config *Env
stmts map[string]*sql.Stmt
db *sql.DB
KVStore map[string]interface{}
mutex *sync.RWMutex
}
//Get - get value from kvstore in memory
func (rt *RuntimeEnv) Get(key string) (value interface{}, ok bool) {
rt.mutex.RLock()
defer rt.mutex.RUnlock()
value, ok = rt.KVStore[key]
return
}
//Retrive - get value from kvstore in memory, and delete it
func (rt *RuntimeEnv) Retrive(key string) (value interface{}, ok bool) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
value, ok = rt.KVStore[key]
if ok {
delete(rt.KVStore, key)
}
return
}
//Set - set value to kvstore in memory
func (rt *RuntimeEnv) Set(key string, value interface{}) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
rt.KVStore[key] = value
}
//EndpointEntry - endpoint registry entry
type EndpointEntry struct {
Handler func(http.ResponseWriter, *http.Request)
Middlewares []endpoint.ServerMiddleware
}
//NewApp - create new app
func NewApp(name, description string, config *Config) *App {
if config == nil {
log.Println("Missing configuration data")
return nil
}
endpoint.SetPrometheus(strings.Replace(name, "-", "_", -1))
app := &App{
Name: name,
Description: description,
Config: config,
Root: root.NewModule(name, description, config.Prefix),
Endpoints: make(map[string]EndpointEntry, 0),
MessageHandlers: make(map[string]func(*module.Message) bool, 0),
Scheduler: cron.New(),
wg: &sync.WaitGroup{},
mutex: &sync.RWMutex{},
Runtime: make(map[string]*RuntimeEnv),
}
app.TaskManager = task.NewManager(app, 100)
return app
}
//Init - app initialization
func (a *App) Init() {
if a.Config != nil {
a.Config.fixPrefix()
for _, env := range a.Config.Envs {
utils.MakeFolder(env.DataFolder)
a.Runtime[env.Name] = &RuntimeEnv{
Config: env,
KVStore: make(map[string]interface{}, 1024),
mutex: &sync.RWMutex{},
}
}
a.InitDB()
}
a.registerEndpoints()
a.registerMessageHandlers()
a.registerTasks()
// utils.LoadOrCreateJSON("./saved_status.json", &a.Status)
a.Root.OnStop = func(p *module.Module) {
a.TaskManager.SendAll("stop")
a.wg.Wait()
}
a.Root.OnDispose = func(p *module.Module) {
for _, env := range a.Runtime {
if env.db != nil {
log.Println("Close sqlite for", env.Config.Name)
env.db.Close()
}
}
// utils.SaveJSON(a.Status, "./saved_status.json")
}
}
//registerEndpoints - Register Endpoints
func (a *App) registerEndpoints() {
a.initEndpoints()
for path, entry := range a.Endpoints {
if entry.Middlewares == nil {
entry.Middlewares = a.getDefaultMiddlewares(path)
}
a.Root.MountingPoints[path] = endpoint.DecorateServer(
endpoint.Impl(entry.Handler),
entry.Middlewares...,
)
}
}
//registerMessageHandlers - Register Message Handlers
func (a *App) registerMessageHandlers() {
a.initMessageHandlers()
for path, handler := range a.MessageHandlers {
a.Root.AddMessageHandler(path, handler)
}
}
//StartScheduler - register and start the scheduled tasks
func (a *App) StartScheduler() {
if a.Scheduler == nil {
a.Scheduler = cron.New()
} else {
a.Scheduler.Stop()
a.Scheduler = cron.New()
}
for _, item := range a.Config.ScheduledTasks {
log.Println("[INFO] - Adding task:", item.Task)
func() {
s := item.Schedule
t := item.Task
a.Scheduler.AddFunc(s, func() {
a.TaskManager.RunTask(t, item.DefaultArgs...)
})
}()
}
a.Scheduler.Start()
}
//ListenAndServe - Start app
func (a *App) ListenAndServe() {
a.Init()
a.StartScheduler()
a.Root.ListenAndServe(a.Config.Address)
}

View File

@ -0,0 +1,66 @@
package main
//GORoutingNumberForWechat - Total GO Routing # for send process
const GORoutingNumberForWechat = 10
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
var cfg = Config{
AppID: "myapp",
Address: ":1501",
Prefix: "/",
RedisServerStr: "localhost:6379",
Envs: []*Env{
{
Name: "prod",
SqliteDB: "prod.db",
DataFolder: "./data/",
},
{
Name: "pp",
SqliteDB: "pp.db",
DataFolder: "./data/",
},
},
ScheduledTasks: []*ScheduledTask{
{Schedule: "0 0 0 * * *", Task: "daily-maintenance", DefaultArgs: []string{}},
{Schedule: "0 10 0 * * *", Task: "daily-maintenance-pp", DefaultArgs: []string{}},
},
}

View File

@ -0,0 +1,79 @@
package main
import "strings"
//Config - data struct for configuration file
type Config struct {
AppID string `json:"appid"`
Address string `json:"address"`
Prefix string `json:"prefix"`
RedisServerStr string `json:"redis-server"`
AppDomainName string `json:"app-domain-name"`
TokenServiceURL string `json:"token-service-url"`
TokenServiceUsername string `json:"token-service-user"`
TokenServicePassword string `json:"token-service-password"`
Envs []*Env `json:"envs,omitempty"`
ScheduledTasks []*ScheduledTask `json:"scheduled-tasks,omitempty"`
}
func (c *Config) fixPrefix() {
if !strings.HasPrefix(c.Prefix, "/") {
c.Prefix = "/" + c.Prefix
}
if !strings.HasSuffix(c.Prefix, "/") {
c.Prefix = c.Prefix + "/"
}
}
//Env - env configuration
type Env struct {
Name string `json:"name,omitempty"`
SqliteDB string `json:"sqlite-db,omitempty"`
DataFolder string `json:"data,omitempty"`
}
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
//ScheduledTask - Scheduled Task
type ScheduledTask struct {
Schedule string `json:"schedule,omitempty"`
Task string `json:"task,omitempty"`
DefaultArgs []string `json:"default-args,omitempty"`
}

View File

@ -0,0 +1,49 @@
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
//InitDB - initialized database
func (a *App) InitDB() {
//init database tables
sqlStmts := []string{
//PV计数
`CREATE TABLE IF NOT EXISTS visit (
openid TEXT DEFAULT '',
pageid TEXT DEFAULT '',
scene TEXT DEFAULT '',
state TEXT INTEGER DEFAULT 0,
pv INTEGER DEFAULT 0,
createat DATETIME,
recent DATETIME
);`,
"CREATE INDEX IF NOT EXISTS idx_visit_openid ON visit(openid);",
"CREATE INDEX IF NOT EXISTS idx_visit_pageid ON visit(pageid);",
"CREATE INDEX IF NOT EXISTS idx_visit_scene ON visit(scene);",
"CREATE INDEX IF NOT EXISTS idx_visit_state ON visit(state);",
"CREATE INDEX IF NOT EXISTS idx_visit_createat ON visit(createat);",
}
var err error
for _, env := range a.Runtime {
env.db, err = sql.Open("sqlite3", fmt.Sprintf("%s%s?cache=shared&mode=rwc", env.Config.DataFolder, env.Config.SqliteDB))
if err != nil {
log.Fatal(err)
}
log.Printf("[INFO] - Initialization DB for [%s]...\n", env.Config.Name)
for _, sqlStmt := range sqlStmts {
_, err := env.db.Exec(sqlStmt)
if err != nil {
log.Printf("[ERR] - [InitDB] %q: %s\n", err, sqlStmt)
return
}
}
env.stmts = make(map[string]*sql.Stmt, 0)
log.Printf("[INFO] - DB for [%s] ready!\n", env.Config.Name)
}
}

View File

@ -0,0 +1,73 @@
package main
import (
"encoding/json"
"loreal.com/dit/utils"
"log"
"net/http"
)
/* 以下为具体 Endpoint 实现代码 */
//entryHandler - entry point for frontend web pages, to get initData in cookie
//endpoint: entry.html
//method: GET
func (a *App) entryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
showError(w, r, "", "无效的方法")
return
}
q := r.URL.Query()
// var userid int64
// userid = -1
appid := sanitizePolicy.Sanitize(q.Get("appid"))
env := a.getEnv(appid)
rt := a.getRuntime(env)
// states := parseState(sanitizePolicy.Sanitize(q.Get("state")))
scene := sanitizePolicy.Sanitize(q.Get("state"))
_ = sanitizePolicy.Sanitize(q.Get("token"))
openid := sanitizePolicy.Sanitize(q.Get("openid"))
dataObject := map[string]interface{}{
"appid": appid,
"scene": scene,
"openid": openid,
}
// follower, nickname := a.wxUserKeyInfo(openid)
// dataObject["nickname"] = nickname
if rt != nil {
// if err := a.recordUser(
// rt,
// openid,
// scene,
// "0",
// &userid,
// ); err != nil {
// log.Println("[ERR] - [EP][entry.html], err:", err)
// }
}
if q.Get("debug") == "1" {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(dataObject); err != nil {
log.Println("[ERR] - JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "text/html;charset=utf-8")
// cookieValue := url.PathEscape(utils.MarshalJSON(dataObject))
if DEBUG {
log.Println("[DEBUG] - set-cookie:", utils.MarshalJSON(dataObject))
}
// http.SetCookie(w, &http.Cookie{
// Name: "initdata",
// Value: cookieValue,
// HttpOnly: false,
// Secure: false,
// MaxAge: 0,
// })
http.ServeFile(w, r, "./public/index.html")
}

View File

@ -0,0 +1,223 @@
package main
import (
"loreal.com/dit/endpoint"
"loreal.com/dit/loreal/webservice"
"loreal.com/dit/middlewares"
"html/template"
"log"
"net/http"
"strconv"
"github.com/microcosm-cc/bluemonday"
)
// var seededRand *rand.Rand
var sanitizePolicy *bluemonday.Policy
var errorTemplate *template.Template
func init() {
// seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
sanitizePolicy = bluemonday.UGCPolicy()
var err error
errorTemplate, _ = template.ParseFiles("./template/error.tpl")
if err != nil {
log.Panic("[ERR] - Parsing error template", err)
}
}
func (a *App) initEndpoints() {
a.Endpoints = map[string]EndpointEntry{
"entry.html": {Handler: a.entryHandler, Middlewares: a.noAuthMiddlewares("entry.html")},
"api/kvstore": {Handler: a.kvstoreHandler, Middlewares: a.noAuthMiddlewares("api/kvstore")},
"api/visit": {Handler: a.pvHandler, Middlewares: a.noAuthMiddlewares("api/visit")},
"error": {Handler: a.errorHandler, Middlewares: a.noAuthMiddlewares("error")},
"report/visit": {Handler: a.reportVisitHandler},
}
}
//noAuthMiddlewares - middlewares without auth
func (a *App) noAuthMiddlewares(path string) []endpoint.ServerMiddleware {
return []endpoint.ServerMiddleware{
middlewares.NoCache(),
middlewares.ServerInstrumentation(path, endpoint.RequestCounter, endpoint.LatencyHistogram, endpoint.DurationsSummary),
}
}
//tokenAuthMiddlewares - middlewares auth by token
// func (a *App) tokenAuthMiddlewares(path string) []endpoint.ServerMiddleware {
// return []endpoint.ServerMiddleware{
// middlewares.NoCache(),
// middlewares.ServerInstrumentation(path, endpoint.RequestCounter, endpoint.LatencyHistogram, endpoint.DurationsSummary),
// a.signatureVerifier(),
// }
// }
//getDefaultMiddlewares - middlewares installed by defaults
func (a *App) getDefaultMiddlewares(path string) []endpoint.ServerMiddleware {
return []endpoint.ServerMiddleware{
middlewares.NoCache(),
middlewares.BasicAuthOrTokenAuthWithRole(a.AuthProvider, "", "user,admin"),
middlewares.ServerInstrumentation(
path,
endpoint.RequestCounter,
endpoint.LatencyHistogram,
endpoint.DurationsSummary,
),
}
}
func (a *App) getEnv(appid string) string {
if appid == a.Config.AppID {
return "prod"
}
return "pp"
}
/* 以下为具体 Endpoint 实现代码 */
//errorHandler - query error info
//endpoint: error
//method: GET
func (a *App) errorHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
return
}
q := r.URL.Query()
title := sanitizePolicy.Sanitize(q.Get("title"))
errmsg := sanitizePolicy.Sanitize(q.Get("errmsg"))
if err := errorTemplate.Execute(w, map[string]interface{}{
"title": title,
"errmsg": errmsg,
}); err != nil {
log.Println("[ERR] - errorTemplate error:", err)
http.Error(w, "500", http.StatusInternalServerError)
}
}
/* 以下为具体 Endpoint 实现代码 */
//kvstoreHandler - get value from kvstore in runtime
//endpoint: /api/kvstore
//method: GET
func (a *App) kvstoreHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
outputJSON(w, webservice.APIStatus{
ErrCode: -100,
ErrMessage: "Method not acceptable",
})
return
}
q := r.URL.Query()
ticket := q.Get("ticket")
env := a.getEnv(q.Get("appid"))
rt := a.getRuntime(env)
if rt == nil {
outputJSON(w, webservice.APIStatus{
ErrCode: -1,
ErrMessage: "invalid appid",
})
return
}
var result struct {
Value interface{} `json:"value"`
}
var ok bool
var v interface{}
v, ok = rt.Retrive(ticket)
if !ok {
outputJSON(w, webservice.APIStatus{
ErrCode: -2,
ErrMessage: "invalid ticket",
})
return
}
switch val := v.(type) {
case chan interface{}:
// log.Println("[Hu Bin] - Get Value Chan:", val)
result.Value = <-val
// log.Println("[Hu Bin] - Get Value from Chan:", result.Value)
default:
// log.Println("[Hu Bin] - Get Value:", val)
result.Value = val
}
outputJSON(w, result)
}
//pvHandler - record PV/UV
//endpoint: /api/visit
//method: GET
func (a *App) pvHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
outputJSON(w, map[string]interface{}{
"code": -1,
"msg": "Not support",
})
return
}
q := r.URL.Query()
appid := q.Get("appid")
env := a.getEnv(appid)
rt := a.getRuntime(env)
if rt == nil {
log.Println("[ERR] - Invalid appid:", appid)
outputJSON(w, map[string]interface{}{
"code": -2,
"msg": "Invalid APPID",
})
return
}
openid := sanitizePolicy.Sanitize(q.Get("openid"))
pageid := sanitizePolicy.Sanitize(q.Get("pageid"))
scene := sanitizePolicy.Sanitize(q.Get("scene"))
visitState, _ := strconv.Atoi(sanitizePolicy.Sanitize(q.Get("type")))
if err := a.recordPV(
rt,
openid,
pageid,
scene,
visitState,
); err != nil {
log.Println("[ERR] - [EP][api/visit], err:", err)
outputJSON(w, map[string]interface{}{
"code": -3,
"msg": "internal error",
})
return
}
outputJSON(w, map[string]interface{}{
"code": 0,
"msg": "ok",
})
}
//CSV BOM
//file.Write([]byte{0xef, 0xbb, 0xbf})
func outputExcel(w http.ResponseWriter, b []byte, filename string) {
w.Header().Add("Content-Disposition", "attachment; filename="+filename)
//w.Header().Add("Content-Type", "application/vnd.ms-excel")
w.Header().Add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
// w.Header().Add("Content-Transfer-Encoding", "binary")
w.Write(b)
}
func outputText(w http.ResponseWriter, b []byte) {
w.Header().Add("Content-Type", "text/plain;charset=utf-8")
w.Write(b)
}
func showError(w http.ResponseWriter, r *http.Request, title, message string) {
if err := errorTemplate.Execute(w, map[string]interface{}{
"title": title,
"errmsg": message,
}); err != nil {
log.Println("[ERR] - errorTemplate error:", err)
http.Error(w, "500", http.StatusInternalServerError)
}
}

View File

@ -0,0 +1,51 @@
package main
import (
"fmt"
"log"
"net/http"
"time"
)
//reportVisitHandler - export visit detail report in excel format
//endpoint: /report/visit
//method: GET
func (a *App) reportVisitHandler(w http.ResponseWriter, r *http.Request) {
const dateFormat = "2006-01-02"
if r.Method != "GET" {
showError(w, r, "明细报表下载", "调用方法不正确")
return
}
q := r.URL.Query()
env := a.getEnv(q.Get("appid"))
rt := a.getRuntime(env)
if rt == nil {
showError(w, r, "明细报表下载", "参数错误, APPID不正确")
return
}
var err error
from, err := time.ParseInLocation(dateFormat, sanitizePolicy.Sanitize(q.Get("from")), time.Local)
if err != nil {
showError(w, r, "明细报表下载", "参数错误, 开始时间from格式不正确")
return
}
to, err := time.ParseInLocation(dateFormat, sanitizePolicy.Sanitize(q.Get("to")), time.Local)
if err != nil {
showError(w, r, "明细报表下载", "参数错误, 结束时间to格式不正确")
return
}
to = to.Add(time.Second*(60*60*24-1) + time.Millisecond*999) //23:59:59.999
xlsxFile, err := a.genVisitReportXlsx(rt, from, to)
if err != nil {
log.Println("[ERR] - [ep][report/download], err:", err)
showError(w, r, "明细报表下载", "查询报表时发生错误, 请联系管理员查看日志。")
return
}
fileName := fmt.Sprintf("visit-report-%s-%s.xlsx", from.Format("0102"), to.Format("0102"))
w.Header().Add("Content-Disposition", "attachment; filename="+fileName)
w.Header().Add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
if err := xlsxFile.Write(w); err != nil {
showError(w, r, "明细报表下载", "查询报表时发生错误, 无法生存Xlsx文件。")
return
}
}

View File

@ -0,0 +1,123 @@
package main
func (a *App) recordPV(
runtime *RuntimeEnv,
openid, pageid, scene string,
visitState int,
) (err error) {
const stmtNameNewPV = "insert-visit"
const stmtSQLNewPV = "INSERT INTO visit (openid,pageid,scene,createAt,recent,pv,state) VALUES (?,?,?,datetime('now','localtime'),datetime('now','localtime'),1,?);"
const stmtNamePV = "update-pv"
const stmtSQLPV = "UPDATE visit SET pv=pv+1,recent=datetime('now','localtime') WHERE openid=? AND pageid=? AND scene=? AND state=?;"
stmtPV := a.getStmt(runtime, stmtNamePV)
if stmtPV == nil {
if stmtPV, err = a.setStmt(runtime, stmtNamePV, stmtSQLPV); err != nil {
return
}
}
stmtNewPV := a.getStmt(runtime, stmtNameNewPV)
if stmtNewPV == nil {
if stmtNewPV, err = a.setStmt(runtime, stmtNameNewPV, stmtSQLNewPV); err != nil {
return
}
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
tx, err := runtime.db.Begin()
if err != nil {
return err
}
stmtPV = tx.Stmt(stmtPV)
pvResult, err := stmtPV.Exec(
openid,
pageid,
scene,
visitState,
)
if err != nil {
tx.Rollback()
return err
}
cnt, err := pvResult.RowsAffected()
if err != nil {
tx.Rollback()
return err
}
if cnt > 0 {
tx.Commit()
return
}
stmtNewPV = tx.Stmt(stmtNewPV)
_, err = stmtNewPV.Exec(
openid,
pageid,
scene,
visitState,
)
if err != nil {
tx.Rollback()
return err
}
tx.Commit()
return
}
// func (a *App) recordQRScan(
// openid string,
// ) (err error) {
// for _, env := range a.Config.Envs {
// rt := a.getRuntime(env.Name)
// if rt == nil {
// continue
// }
// a.doRecordQRScan(rt, openid)
// }
// return nil
// }
// func (a *App) doRecordQRScan(
// runtime *RuntimeEnv,
// openid string,
// ) (err error) {
// const stmtNameAdd = "add-openid"
// const stmtSQLAdd = "INSERT INTO qrscan (openid,createat) VALUES (?,datetime('now','localtime'));"
// const stmtNameRecord = "record-scan"
// const stmtSQLRecord = "UPDATE qrscan SET scanCnt=scanCnt+1,recent=datetime('now','localtime') WHERE openid=?;"
// stmtAdd := a.getStmt(runtime, stmtNameAdd)
// if stmtAdd == nil {
// //lazy setup for stmt
// if stmtAdd, err = a.setStmt(runtime, stmtNameAdd, stmtSQLAdd); err != nil {
// return
// }
// }
// stmtRecord := a.getStmt(runtime, stmtSQLRecord)
// if stmtRecord == nil {
// if stmtRecord, err = a.setStmt(runtime, stmtNameRecord, stmtSQLRecord); err != nil {
// return
// }
// }
// runtime.mutex.Lock()
// defer runtime.mutex.Unlock()
// tx, err := runtime.db.Begin()
// if err != nil {
// return err
// }
// //Add scan
// stmtAdd = tx.Stmt(stmtAdd)
// _, err = stmtAdd.Exec(
// openid,
// )
// if err != nil && !strings.HasPrefix(err.Error(), "UNIQUE") {
// tx.Rollback()
// return err
// }
// //record scan
// stmtRecord = tx.Stmt(stmtRecord)
// _, err = stmtRecord.Exec(openid)
// if err != nil {
// tx.Rollback()
// return err
// }
// tx.Commit()
// return
// }

View File

@ -0,0 +1,188 @@
package main
import (
"database/sql"
"encoding/json"
"loreal.com/dit/module/modules/loreal"
"loreal.com/dit/wechat"
"fmt"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
)
//DEBUG - whether in debug mode
var DEBUG bool
//INFOLEVEL - info level for debug mode
var INFOLEVEL int
//LOGLEVEL - info level for logs
var LOGLEVEL int
var wxAccount *wechat.Account
func init() {
wxAccount = wechat.Accounts["default"]
if wxAccount.Token.Requester == nil {
wxAccount.Token.Requester = loreal.NewWechatTokenService(wxAccount)
}
if os.Getenv("EV_DEBUG") != "" {
DEBUG = true
}
INFOLEVEL = 1
LOGLEVEL = 1
}
//var wxAccount = wechat.Accounts["default"]
func lorealCardValid(cardNo string) bool {
if len(cardNo) != 22 {
return false
}
re := regexp.MustCompile("\\d{22}")
if !re.MatchString(cardNo) {
return false
}
return (cardNo == lorealCardCheckSum(cardNo[:20]))
}
//lorealCardCheckSum calculate 2 check sum bit for loreal card
func lorealCardCheckSum(cardNo string) string {
firstLineWeight := []int{1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2}
secondLineWeight := []int{4, 3, 2, 7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2}
cardRunes := []rune(cardNo)
var firstDigital, secondDigital int
var temp, result int
for i := 0; i <= 19; i++ {
cardBit, _ := strconv.Atoi(string(cardRunes[i]))
temp = cardBit * firstLineWeight[i]
if temp > 9 {
temp -= 9
}
result += temp
}
firstDigital = result % 10
if firstDigital != 0 {
firstDigital = 10 - firstDigital
}
cardNo = fmt.Sprintf("%s%d", cardNo, firstDigital)
cardRunes = []rune(cardNo)
result = 0
for i := 0; i <= 20; i++ {
cardBit, _ := strconv.Atoi(string(cardRunes[i]))
temp = cardBit * secondLineWeight[i]
result += temp
}
secondDigital = 11 - result%11
if secondDigital > 9 {
secondDigital = 0
}
return fmt.Sprintf("%s%d", cardNo, secondDigital)
}
func retry(count int, fn func() error) error {
total := count
retry:
err := fn()
if err != nil {
count--
log.Println("[INFO] - Retry: ", total-count)
if count > 0 {
goto retry
}
}
return err
}
func parseState(state string) map[string]string {
result := make(map[string]string, 2)
var err error
state, err = url.PathUnescape(state)
if err != nil {
log.Println("[ERR] - parseState", err)
return result
}
if DEBUG {
log.Println("[DEBUG] - PathUnescape state:", state)
}
states := strings.Split(state, ";")
for _, kv := range states {
sp := strings.Index(kv, ":")
if sp < 0 {
//empty value
result[kv] = ""
continue
}
result[kv[:sp]] = kv[sp+1:]
}
return result
}
func (a *App) getRuntime(env string) *RuntimeEnv {
runtime, ok := a.Runtime[env]
if !ok {
return nil
}
return runtime
}
//getStmt - get stmt from app safely
func (a *App) getStmt(runtime *RuntimeEnv, name string) *sql.Stmt {
runtime.mutex.RLock()
defer runtime.mutex.RUnlock()
if stmt, ok := runtime.stmts[name]; ok {
return stmt
}
return nil
}
//getStmt - get stmt from app safely
func (a *App) setStmt(runtime *RuntimeEnv, name, query string) (stmt *sql.Stmt, err error) {
stmt, err = runtime.db.Prepare(query)
if err != nil {
logError(err, name)
return nil, err
}
runtime.mutex.Lock()
runtime.stmts[name] = stmt
runtime.mutex.Unlock()
return stmt, nil
}
//outputJSON - output json for http response
func outputJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
enc := json.NewEncoder(w)
if DEBUG {
enc.SetIndent("", " ")
}
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}
func logError(err error, msg string) {
if err != nil {
log.Printf("[ERR] - %s, err: %v\n", msg, err)
}
}
func debugInfo(source, msg string, level int) {
if DEBUG && INFOLEVEL >= level {
log.Printf("[DEBUG] - [%s]%s\n", source, msg)
}
}

View File

@ -0,0 +1,86 @@
package main
import (
"fmt"
"time"
"github.com/tealeg/xlsx"
)
/*
openid TEXT DEFAULT '',
pageid TEXT DEFAULT '',
scene TEXT DEFAULT '',
state TEXT INTEGER DEFAULT 0,
pv INTEGER DEFAULT 0,
createat DATETIME,
recent DATETIME
*/
type visitRecord struct {
OpenID string
PageID string
Scene string
PV int
CreateAt time.Time
Recent time.Time
}
func (a *App) genVisitReportXlsx(
runtime *RuntimeEnv,
from time.Time,
to time.Time,
) (f *xlsx.File, err error) {
const stmtName = "visit-report"
const stmtSQL = "SELECT openid,pageid,scene,pv,createat,recent FROM visit WHERE createat>=? AND createat<=?;"
stmt := a.getStmt(runtime, stmtName)
if stmt == nil {
//lazy setup for stmt
if stmt, err = a.setStmt(runtime, stmtName, stmtSQL); err != nil {
return
}
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
rows, err := stmt.Query(from, to)
if err != nil {
return
}
var headerRow *xlsx.Row
f = xlsx.NewFile()
sheet, err := f.AddSheet(fmt.Sprintf("PV details %s-%s", from.Format("0102"), to.Format("0102")))
if err != nil {
return nil, err
}
headerRow = sheet.AddRow()
headerRow.AddCell().SetString("OpenID")
headerRow.AddCell().SetString("页面号")
headerRow.AddCell().SetString("入口场景")
headerRow.AddCell().SetString("PV")
headerRow.AddCell().SetString("首次进入时间")
headerRow.AddCell().SetString("最后进入时间")
for rows.Next() {
r := sheet.AddRow()
vr := visitRecord{}
if err = rows.Scan(
&vr.OpenID,
&vr.PageID,
&vr.Scene,
&vr.PV,
&vr.CreateAt,
&vr.Recent,
); err != nil {
return
}
r.AddCell().SetString(vr.OpenID)
r.AddCell().SetString(vr.PageID)
r.AddCell().SetString(vr.Scene)
r.AddCell().SetInt(vr.PV)
r.AddCell().SetDateTime(vr.CreateAt)
r.AddCell().SetDateTime(vr.Recent)
}
return f, nil
}

View File

@ -0,0 +1,27 @@
package main
import (
"loreal.com/dit/utils/task"
)
//DailyMaintenance - task to do daily maintenance
func (a *App) DailyMaintenance(t *task.Task) (err error) {
// const stmtName = "dm-clean-vehicle"
// const stmtSQL = "DELETE FROM vehicle_left WHERE enter<=?;"
// env := getEnv(t.Context)
// runtime := a.getRuntime(env)
// if runtime == nil {
// return ErrMissingRuntime
// }
// stmt := a.getStmt(runtime, stmtName)
// if stmt == nil {
// //lazy setup for stmt
// if stmt, err = a.setStmt(runtime, stmtName, stmtSQL); err != nil {
// return err
// }
// }
// runtime.mutex.Lock()
// defer runtime.mutex.Unlock()
// _, err = stmt.Exec(int(time.Now().Add(time.Hour * -168).Unix())) /* 7*24Hours = 168*/
return nil
}

View File

@ -0,0 +1,56 @@
//General Wechat WebAPP Host
package main
import (
"flag"
"log"
"math/rand"
"time"
"loreal.com/dit/module/modules/account"
"loreal.com/dit/module/modules/wechat"
"loreal.com/dit/utils"
)
//Version - generate on build time by makefile
var Version = "v0.1"
//CommitID - generate on build time by makefile
var CommitID = ""
func main() {
rand.Seed(time.Now().UnixNano())
const serviceName = "wxAppHost"
const serviceDescription = "Wechat WebAPP Host"
log.Println("[INFO] -", serviceName, Version+"-"+CommitID)
log.Println("[INFO] -", serviceDescription)
utils.LoadOrCreateJSON("./config/config.json", &cfg) //cfg initialized in config.go
flag.StringVar(&cfg.Address, "addr", cfg.Address, "host:port of the service")
flag.StringVar(&cfg.Prefix, "prefix", cfg.Prefix, "/path/ prefixe to service")
flag.StringVar(&cfg.RedisServerStr, "redis", cfg.RedisServerStr, "Redis connection string")
flag.StringVar(&cfg.AppDomainName, "app-domain", cfg.AppDomainName, "app domain name")
flag.Parse()
//Create Main service
var app = NewApp(serviceName, serviceDescription, &cfg)
uas := account.NewModule("account",
app.Config.RedisServerStr, /*Redis server address*/
3, /*Numbe of faild logins to lock the account */
60*time.Second, /*How long the account will stay locked*/
7200*time.Second, /*How long the token will be valid*/
)
app.Root.Install(
uas,
wechat.NewModuleWithCEHTokenService(
"wx",
app.Config.AppDomainName,
app.Config.TokenServiceURL,
app.Config.TokenServiceUsername,
app.Config.TokenServicePassword,
),
)
app.AuthProvider = uas
app.ListenAndServe()
}

View File

@ -0,0 +1,25 @@
package main
import (
"log"
"loreal.com/dit/module"
"loreal.com/dit/utils"
)
func (a *App) initMessageHandlers() {
a.MessageHandlers = map[string]func(*module.Message) bool{
"reload": a.reloadMessageHandler,
}
}
//reloadMessageHandler - handle reload message
func (a *App) reloadMessageHandler(msgPtr *module.Message) (handled bool) {
//reload configuration
utils.LoadOrCreateJSON("./config/config.json", &a.Config)
a.Config.fixPrefix()
a.StartScheduler()
log.Println("[INFO] - Configuration reloaded!")
return true
}

View File

@ -0,0 +1,14 @@
package main
import "loreal.com/dit/utils/task"
func (a *App) registerTasks() {
a.TaskManager.RegisterWithContext("daily-maintenance-pp", "xmillion-test", a.dailyMaintenanceTaskHandler, 1)
a.TaskManager.RegisterWithContext("daily-maintenance", "xmillion", a.dailyMaintenanceTaskHandler, 1)
}
//dailyMaintenanceTaskHandler - run daily maintenance task
func (a *App) dailyMaintenanceTaskHandler(t *task.Task, args ...string) {
//a.DailyMaintenance(t, task.GetArgs(args, 0))
a.DailyMaintenance(t)
}

View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "api test",
"type": "go",
"request": "launch",
"mode": "debug",
// "backend": "native",
"program": "${workspaceFolder}/main.go"
}
]
}

View File

@ -0,0 +1,105 @@
package base
import (
// "bytes"
"encoding/json"
// "errors"
"fmt"
"log"
"math/rand"
// "mime/multipart"
"net/http"
"strings"
"time"
"github.com/google/uuid"
)
var r *rand.Rand = rand.New(rand.NewSource(time.Now().Unix()))
// IsBlankString 判断是否为空的IDID不能都是空白.
func IsBlankString(str string) bool {
return len(strings.TrimSpace(str)) == 0
}
// IsEmptyString 判断是否为空的字符串.
func IsEmptyString(str string) bool {
return len(str) == 0
}
// IsValidUUID
func IsValidUUID(u string) bool {
_, err := uuid.Parse(u)
return err == nil
}
// SetResponseHeader 一个快捷设置status code 和content type的方法
func SetResponseHeader(w http.ResponseWriter, statusCode int, contentType string) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(statusCode)
}
// WriteErrorResponse 一个快捷设置包含错误body的response
func WriteErrorResponse(w http.ResponseWriter, statusCode int, contentType string, a interface{}) {
SetResponseHeader(w, statusCode, contentType)
switch vv := a.(type) {
case error:
{
fmt.Fprintf(w, vv.Error())
}
case map[string][]error:
{
jsonBytes, err := json.Marshal(vv)
if nil != err {
log.Println(err)
fmt.Fprintf(w, err.Error())
}
var str = string(jsonBytes)
fmt.Fprintf(w, str)
}
case []error:
{
jsonBytes, err := json.Marshal(vv)
if nil != err {
log.Println(err)
fmt.Fprintf(w, err.Error())
}
var str = string(jsonBytes)
fmt.Fprintf(w, str)
}
}
}
// trimURIPrefix 将一个uri拆分为若干node根据ndoe取得一些动态参数。
func TrimURIPrefix(uri string, stopTag string) []string {
params := strings.Split(strings.TrimPrefix(strings.TrimSuffix(uri, "/"), "/"), "/")
last := len(params) - 1
for i := last; i >= 0; i-- {
if params[i] == stopTag {
return params[i+1:]
}
}
return params
}
//outputJSON - output json for http response
func outputJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
enc := json.NewEncoder(w)
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - [outputJSON] JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}
// RandString 生成随机字符串
func RandString(len int) string {
bytes := make([]byte, len)
for i := 0; i < len; i++ {
b := r.Intn(26) + 65
bytes[i] = byte(b)
}
return string(bytes)
}

View File

@ -0,0 +1,27 @@
module api-tests-for-coupon-service
go 1.13
require (
github.com/ajg/form v1.5.1 // indirect
github.com/chenhg5/collection v0.0.0-20191118032303-cb21bccce4c3
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gavv/httpexpect v2.0.0+incompatible
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.1.1
github.com/gorilla/websocket v1.4.1 // indirect
github.com/imkira/go-interpol v1.1.0 // indirect
github.com/jinzhu/now v1.1.1
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/moul/http2curl v1.0.0 // indirect
github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3
github.com/sergi/go-diff v1.1.0 // indirect
github.com/smartystreets/goconvey v1.6.4
github.com/valyala/fasthttp v1.9.0 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
github.com/yudai/gojsondiff v1.0.0 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
)

View File

@ -0,0 +1,181 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/chenhg5/collection v0.0.0-20191118032303-cb21bccce4c3 h1:77Y9kzo3hVaRyipak3XyNVVUfJXyofQeUWoxaiEgPwk=
github.com/chenhg5/collection v0.0.0-20191118032303-cb21bccce4c3/go.mod h1:RE3lB6QNf4YUL8Jl/OONdlltQuN9LfZD8eR3nZZdBLA=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
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/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 h1:DddqAaWDpywytcG8w/qoQ5sAN8X12d3Z3koB0C3Rxsc=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1 h1:OQl5ys5MBea7OGCdvPbBJWRgnhC/fGona6QKfvFeau8=
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuTg=
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
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/klauspost/compress v1.8.2 h1:Bx0qjetmNjdFXASH02NSAREKpiaDwkO1DRZ3dV2KCcs=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w=
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.4.0 h1:LUa41nrWTQNGhzdsZ5lTnkwbNjj6rXTdazA1cSdjkOY=
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3 h1:xkBtI5JktwbW/vf4vopBbhYsRFTGfQWHYXzC0/qYwxI=
github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
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/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
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/fasthttp v1.9.0 h1:hNpmUdy/+ZXYpGy0OBfm7K0UQTzb73W0T0U4iJIVrMw=
github.com/valyala/fasthttp v1.9.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4 h1:ydJNl0ENAG67pFbB+9tfhiL2pYqLhfoaZFw/cjLhY4A=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438 h1:khxRGsvPk4n2y8I/mLLjp7e5dMTJmH75wvqS6nMwUtY=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5 h1:ymVxjfMaHvXD8RqPRmzHHsB3VvucivSkIAvJFDI5O3c=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -0,0 +1,9 @@
package main
import (
"fmt"
)
func main() {
fmt.Println("this project only for api test")
}

View File

@ -0,0 +1,131 @@
package main
import (
"api-tests-for-coupon-service/base"
"fmt"
"net/http"
"strings"
"testing"
"time"
"github.com/gavv/httpexpect"
"github.com/google/uuid"
"github.com/jinzhu/now"
. "github.com/smartystreets/goconvey/convey"
)
func Test_APPLY_TIMES(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("coupon A only can apply one time", t, func() {
u := uuid.New().String()
_post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
_post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusBadRequest)
})
Convey("coupon B can apply upto 2 times", t, func() {
u := uuid.New().String()
_post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusOK)
_post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusOK)
_post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusBadRequest)
})
}
func Test_REDEEM_TIMES(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("coupon A only can redeem one time", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
Convey("coupon B can redeem upto 2 times", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
}
func Test_REDEEM_BY_SAME_BRAND(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("coupon issued by lancome can't be redeemed by parise", t, func() {
u := uuid.New().String()
_, cid := _post1CouponWithToken(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusOK, jwttony5000d)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
}
func Test_REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("coupon A can not redeem in next month", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
ct := time.Now().AddDate(0, 0, -31)
sql := fmt.Sprintf(`UPDATE coupons SET createdTime = "%s" WHERE id = "%s"`, ct, cid)
_, _ = dbConnection.Exec(sql)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
Convey("coupon C can not redeem since expired", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeC, channelID, http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
Convey("coupon B can redeem in next month", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeB, channelID, http.StatusOK)
ct := now.BeginningOfMonth().AddDate(0, 0, -5).Local() // coupon b 延长了31天。
sql := fmt.Sprintf(`UPDATE coupons SET createdTime = %d WHERE id = "%s"`, ct.Unix(), cid)
_, _ = dbConnection.Exec(sql)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
})
Convey("coupon D can not redeem in the month after next month", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeD, channelID, http.StatusOK)
ct := now.BeginningOfMonth().AddDate(0, 0, -35) // 因为月份天数不一致如果今天是1号或者2号可能会失败。
sql := fmt.Sprintf(`UPDATE coupons SET createdTime = %d WHERE id = "%s"`, ct.Unix(), cid)
_, _ = dbConnection.Exec(sql)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusBadRequest)
})
}
func Test_REDEEM_PERIOD_WITH_OFFSET(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("coupon E can redeem after issued", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeE, channelID, http.StatusOK)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
})
Convey("coupon E can redeem after 100 days", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeE, channelID, http.StatusOK)
ct := time.Now().AddDate(0, 0, -100)
sql := fmt.Sprintf(`UPDATE coupons SET createdTime = "%s" WHERE id = "%s"`, ct, cid)
_, _ = dbConnection.Exec(sql)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
})
Convey("coupon E can not redeem after 365 days", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeE, channelID, http.StatusOK)
ct := time.Now().AddDate(0, 0, -365)
sql := fmt.Sprintf(`UPDATE coupons SET createdTime = "%s" WHERE id = "%s"`, ct, cid)
_, _ = dbConnection.Exec(sql)
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
})
}

View File

@ -0,0 +1,213 @@
package main
import (
"api-tests-for-coupon-service/base"
// "database/sql"
// "fmt"
"net/http"
"strings"
"testing"
"github.com/gavv/httpexpect"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
. "github.com/smartystreets/goconvey/convey"
)
var baseurl string = "http://127.0.0.1:1503"
var jwtyhl5000d string = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvbXZRTEFoTWxVcUsxSjItQmhFYl82QlNkTFpmNkhSVVgtUlhXcHRINElFIn0.eyJqdGkiOiI5NWE1N2E0NS1lOGU3LTRhYzEtOTNhYi00NDI0M2ExNTg3YWMiLCJleHAiOjIwMTMyMjUwMTYsIm5iZiI6MCwiaWF0IjoxNTgxMjI1MDE2LCJpc3MiOiJodHRwOi8vNTIuMTMwLjczLjE4MC9hdXRoL3JlYWxtcy9Mb3JlYWwiLCJzdWIiOiIzNzk1MzdkYy1mYzdkLTRmMzAtOWExMy0wOWFjNmY4OGZhYjYiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJzb21lYXBwIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiY2I2NDllOGQtZDkwNC00M2E3LWE2NDMtNGY1YTNmMDhmMmVkIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjb3Vwb25fcmVkZWVtZXIiLCJjb3Vwb25faXNzdWVyIl19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InlobDEwMDAwIiwiYnJhbmQiOiJMQU5DT01FIn0.jiRcTomtwvCnyFg2PxZA2MfK36UmD0tsx9kz3PNjlo4tNoxVPax-ocdln5qQfrJg6yzASwsg_-gNFgLhUqCCUV0iGLXcf69fBxvuxcQyBszmcByPda55u_zvLrv-91mI0a167ipjaIWqL2uOo_lSPm44JpwBcex2nqjz1FFBk1g3-nAHPuceh4-c0cF0y51QlS4fSCexorYDlIhUtcym6YQn9hmrM6Xdtjdtgf4iG_srnlH3gIAckT-Ihq_7rueNRE6cniabXg5AkzluEIwDwxY9KbPjSQ6Y1mxAleZ_dIvLFXzjxbnXn1vm8jRt3MAtvxG5yQ0sKjyb9j7h8jGzPA"
var jwttony5000d string = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvbXZRTEFoTWxVcUsxSjItQmhFYl82QlNkTFpmNkhSVVgtUlhXcHRINElFIn0.eyJqdGkiOiJiYjUxNWYyZC01ZDA0LTQxYzctYWM0My00ZGRiMTQxODBiNDMiLCJleHAiOjIwMTQwNzg2OTAsIm5iZiI6MCwiaWF0IjoxNTgyMDc4NjkwLCJpc3MiOiJodHRwOi8vNTIuMTMwLjczLjE4MC9hdXRoL3JlYWxtcy9Mb3JlYWwiLCJzdWIiOiI5ZDU3ZGFmMi0xN2Q3LTRiYjMtYjk1Mi00ODY1MTMzNjkwMzgiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJzb21lYXBwIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiZjAwNDhhMWItOGFjOC00ZTA1LWI2Y2QtN2RhZTNmYTU4ZjFhIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjb3Vwb25fcmVkZWVtZXIiLCJjb3Vwb25faXNzdWVyIl19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InRvbnkiLCJicmFuZCI6IlBBUklTX0xPUkVBTCJ9.Z0-ZKZXcqcATnjDpfVYFelq4scCBMP1l9LuCC8zUYmzJwBgo56JwdhVaQO-_sQeaqU__a7gGz8P2DKxv_Y6mbc5jTh5BWcc7AC9LR5axZFzHVTgp_ZE7FCBEHkmYpF72W6BKI73e-0_Qwn1FVRxWAF7KkuSnV5_hdfi6-CStfREikE5_Wr0VGS9mn_fcmuVbGchE1yzHhDGKmVa2RiypcMWGHcSY6iF9FTkbF9TbZ-Lu-ASJRFQ8U-k7Q4wtd8laMQTctEJHMVXQ0GcQ362J5l42OiCZjjTleMghx05gvbjhaCF8FI0YdgaBkPUQ-hw_C4IO2fJdc6x4CkdTolgHQg"
var jwt1s string = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvbXZRTEFoTWxVcUsxSjItQmhFYl82QlNkTFpmNkhSVVgtUlhXcHRINElFIn0.eyJqdGkiOiJmZWExMDE3NS1jM2Q4LTQ5ZDEtYjI1NS1kODcwYTJiNWFmYmQiLCJleHAiOjE1ODEyMjUxNjgsIm5iZiI6MCwiaWF0IjoxNTgxMjI1MTA4LCJpc3MiOiJodHRwOi8vNTIuMTMwLjczLjE4MC9hdXRoL3JlYWxtcy9Mb3JlYWwiLCJzdWIiOiIzNzk1MzdkYy1mYzdkLTRmMzAtOWExMy0wOWFjNmY4OGZhYjYiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJzb21lYXBwIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiYjJkODdmMjYtMGEyNC00MTQxLWI1MmItMWJhNTFiOGQ4NmYxIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjb3Vwb25fcmVkZWVtZXIiLCJjb3Vwb25faXNzdWVyIl19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InlobDEwMDAwIiwiYnJhbmQiOiJMQU5DT01FIn0.wACD6pdlnGEZdgMmToCQnp29L5wTxjwsCBB0qPNhULZ3ZSIXAIoC7bb3Rjzomk_FpJjCpBlcmfm83kU1UbDQuiKSkI6DOemZMbccAfHfnqEu3V7195-pBTIWsEpVpKNCFI4lXAKBo7IsHBJwWfMrdkSdUljYWIC_7LgH3vVmY6LheEszRcl9P3NbXaDcmWYtjuywIS7Aph5wse8671aJ7w2ahyDLsr7prlUNs0K9rJMgGy1DNJhzVaGXQnEmg2IMWQihJT1YGzWTzTE24YieQ0BYzvHPfwPsDxyiDZS6qj3z9qBugmFAgJulU7AnoAmEdEFSdMHN_0QwqlvzhGLlrg"
var jwtnoissueredeemrole string = "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJvbXZRTEFoTWxVcUsxSjItQmhFYl82QlNkTFpmNkhSVVgtUlhXcHRINElFIn0.eyJqdGkiOiI5YTUxNDU0My02YWQ3LTRmMjMtOGJiYS1lZGFmNzI5NDRlZDEiLCJleHAiOjIwMTMzMDQ4MTYsIm5iZiI6MCwiaWF0IjoxNTgxMzA0ODE2LCJpc3MiOiJodHRwOi8vNTIuMTMwLjczLjE4MC9hdXRoL3JlYWxtcy9Mb3JlYWwiLCJzdWIiOiIzNzk1MzdkYy1mYzdkLTRmMzAtOWExMy0wOWFjNmY4OGZhYjYiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJzb21lYXBwIiwiYXV0aF90aW1lIjowLCJzZXNzaW9uX3N0YXRlIjoiOGU2NzVhYTUtZDMxZi00YTFkLWI3OGItYjllYjBhOTYyODg1IiwiYWNyIjoiMSIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoieWhsMTAwMDAiLCJicmFuZCI6IkxBTkNPTUUifQ.PwCTxuLcMwz4-XyoBd750rk7T2qXU6zBdLtSEUa7pMGPI5Eo7Ayp0Yub15EraIoZP0Kphv9MdFtKkNMuV4Ua9otSDkPe2CSn4be3Ez-gvhk7Gbylh1atyrSpdaW-QJNqCek3C8jvGnC4c3_9o4Bduj6pnrRtrttM-oEFGrtauahIr73vmBuRalokw7OMm2dfq3ot8hTb1oT5RkPt_IILxTIorxMKUSoetKUM9b87KHv7EMZQz0sZne8EQ6DrpZmZDAsxU4RL5osKpgYH6p7XACG8RznZtDDfN0uC87nRiUyRbHYHetRwyu_AlnxpRfyCtFCrOFYn00hdrQYO7wi0FA"
var typeA string = "63f9f1ce-2ad0-462a-b798-4ead5e5ab3a5"
var typeB string = "58b388ff-689e-445a-8bbd-8d707dbe70ef"
var typeC string = "abd73dbe-cc91-4b61-b10c-c6532d7a7770"
var typeD string = "ca0ff68f-dc05-488d-b185-660b101a1068"
var typeE string = "7c0fb6b2-7362-4933-ad15-cd4ad9fccfec"
var channelID string = "defaultChannel"
// TODO: 需要直接在DB插入数据然后做一些验证因为现在无法测试一些和时间有关的case
func Test_Authorization(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Given a request without Authorization", t, func() {
e.GET("/api/coupontypes").
Expect().Status(http.StatusUnauthorized)
})
Convey("Given a request with a expired jwt", t, func() {
e.GET("/api/coupontypes").
WithHeader("Authorization", jwt1s).
Expect().Status(http.StatusBadRequest).
JSON().Object().ContainsKey("error-code").ValueEqual("error-code", 1501)
})
}
func Test_GET_coupontypes(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Make sure api only support GET", t, func() {
e.POST("/api/coupontypes").WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusMethodNotAllowed)
e.PUT("/api/coupontypes").WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusMethodNotAllowed)
e.DELETE("/api/coupontypes").WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusMethodNotAllowed)
e.PATCH("/api/coupontypes").WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusMethodNotAllowed)
})
Convey("Given a person with no required roles", t, func() {
e.GET("/api/coupontypes").
WithHeader("Authorization", jwtnoissueredeemrole).
Expect().Status(http.StatusForbidden)
})
Convey("Given a right person to get the coupon types", t, func() {
arr := e.GET("/api/coupontypes").
WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusOK).
JSON().Array()
arr.Length().Gt(0)
obj := arr.Element(0).Object()
obj.ContainsKey("name")
obj.ContainsKey("id")
obj.ContainsKey("template_id")
obj.ContainsKey("description")
obj.ContainsKey("internal_description")
obj.ContainsKey("state")
obj.ContainsKey("publisher")
obj.ContainsKey("visible_start_time")
obj.ContainsKey("visible_end_time")
obj.ContainsKey("created_time")
obj.ContainsKey("deleted_time")
obj.ContainsKey("rules")
})
}
func Test_POST_coupons(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Issue coupon A for one consumer", t, func() {
u := uuid.New().String()
c, _ := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
_validateCouponWithTypeA(c, u, strings.Join([]string{u, "xx"}, ""), channelID, 0)
})
Convey("Issue coupon of type A for multi consumers", t, func() {
u1 := uuid.New().String()
u2 := uuid.New().String()
c1, c2, _, _ := _post2Coupons(e, u1, u2, strings.Join([]string{u1, "xx"}, ""), strings.Join([]string{u2, "yy"}, ""), typeA, channelID)
Convey("the coupons should be same as created", func() {
_validateCouponWithTypeA(c1, u1, strings.Join([]string{u1, "xx"}, ""), channelID, 0)
_validateCouponWithTypeA(c2, u2, strings.Join([]string{u2, "yy"}, ""), channelID, 0)
})
})
}
func Test_Get_coupon(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Issue coupon A for one consumer", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
// cid := oc.Value("ID").String().Raw()
Convey("Get the coupon and validate", func() {
c := _getCouponByID(e, cid)
_validateCouponWithTypeA(c, u, strings.Join([]string{u, "xx"}, ""), channelID, 0)
})
})
Convey("Issue coupon of type A for multi consumers", t, func() {
u1 := uuid.New().String()
u2 := uuid.New().String()
_, _, cid1, cid2 := _post2Coupons(e, u1, u2, strings.Join([]string{u1, "xx"}, ""), strings.Join([]string{u2, "yy"}, ""), typeA, channelID)
Convey("Get the coupon c1", func() {
c1 := _getCouponByID(e, cid1)
_validateCouponWithTypeA(c1, u1, strings.Join([]string{u1, "xx"}, ""), channelID, 0)
})
Convey("Get the coupon c2", func() {
c2 := _getCouponByID(e, cid2)
_validateCouponWithTypeA(c2, u2, strings.Join([]string{u2, "yy"}, ""), channelID, 0)
})
})
}
func Test_Get_coupons(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Issue coupon A, B for one consumer", t, func() {
u := uuid.New().String()
uref := strings.Join([]string{u, "xx"}, "")
_, cid1 := _post1Coupon(e, u, uref, typeA, channelID, http.StatusOK)
_post1Coupon(e, u, uref, typeB, channelID, http.StatusOK)
// _, _, cid1, _ := _post2Coupons(e, u, u, uref, uref, typeA, typeB, channelID)
Convey("Get the coupons", func() {
arr := _getCoupons(e, u)
arr.Length().Equal(2)
var c1, c2 *httpexpect.Object
if arr.Element(0).Object().Value("ID").String().Raw() == cid1 {
c1 = arr.Element(0).Object()
c2 = arr.Element(1).Object()
} else {
c1 = arr.Element(1).Object()
c2 = arr.Element(0).Object()
}
Convey("the coupons should be same as created", func() {
_validateCouponWithTypeA(c1, u, strings.Join([]string{u, "xx"}, ""), channelID, 0)
_validateCouponWithTypeB(c2, u, strings.Join([]string{u, "xx"}, ""), channelID, 0)
})
})
})
}
func Test_POST_redemptions(t *testing.T) {
e := httpexpect.New(t, baseurl)
Convey("Given a coupon for redeem by coupon id", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
Convey("Redeem the coupon", func() {
_redeemCouponByID(e, u, cid, base.RandString(4), http.StatusOK)
_getCouponByID(e, cid).ContainsKey("State").ValueEqual("State", 2)
})
})
Convey("Given 2 coupons to redeem by coupon type", t, func() {
u1 := uuid.New().String()
u2 := uuid.New().String()
_, _, cid1, cid2 := _post2Coupons(e, u1, u2, strings.Join([]string{u1, "xx"}, ""), strings.Join([]string{u2, "yy"}, ""), typeA, channelID)
Convey("Redeem the coupons", func() {
extraInfo := base.RandString(4)
e.POST("/api/redemptions").WithHeader("Authorization", jwtyhl5000d).
WithForm(map[string]interface{}{
"consumerIDs": strings.Join([]string{u1, u2}, ","),
// "couponID": cid,
"couponTypeID": typeA,
"extraInfo": extraInfo,
}).Expect().Status(http.StatusOK)
_getCouponByID(e, cid1).ContainsKey("State").ValueEqual("State", 2)
_getCouponByID(e, cid2).ContainsKey("State").ValueEqual("State", 2)
})
})
Convey("Redeem coupon should bind correct consumer", t, func() {
u := uuid.New().String()
_, cid := _post1Coupon(e, u, strings.Join([]string{u, "xx"}, ""), typeA, channelID, http.StatusOK)
Convey("Redeem the coupon by another one should failed", func() {
_redeemCouponByID(e, "hacker", cid, base.RandString(4), http.StatusBadRequest)
_getCouponByID(e, cid).ContainsKey("State").ValueEqual("State", 0)
})
})
}

View File

@ -0,0 +1,232 @@
package main
import (
"database/sql"
"fmt"
"net/http"
"strings"
// "fmt"
"math/rand"
// "reflect"
"testing"
"time"
"github.com/gavv/httpexpect"
)
var r *rand.Rand = rand.New(rand.NewSource(time.Now().Unix()))
const defaultCouponTypeID string = "678719f5-44a8-4ac8-afd0-288d2f14daf8"
const anotherCouponTypeID string = "dff0710e-f5af-4ecf-a4b5-cc5599d98030"
var dbConnection *sql.DB
func TestMain(m *testing.M) {
_setUp()
m.Run()
_tearDown()
}
func _setUp() {
client := &http.Client{}
// 激活测试数据,未来会重构
req, err := http.NewRequest("GET", baseurl+"/api/apitester", strings.NewReader("name=cjb"))
if err != nil {
fmt.Println(err.Error())
}
req.Header.Set("Authorization", jwtyhl5000d)
resp, err := client.Do(req)
if err != nil {
fmt.Println(err.Error())
}
fmt.Println(resp.Status)
dbConnection, err = sql.Open("sqlite3", "../coupon-service/data/data.db?cache=shared&mode=rwc")
if err != nil {
panic(err)
}
}
func _tearDown() {
}
// func _aCoupon(consumerID string, consumerRefID string, channelID string, couponTypeID string, state State, p map[string]interface{}) *Coupon {
// lt := time.Now().Local()
// var c = Coupon{
// ID: uuid.New().String(),
// CouponTypeID: couponTypeID,
// ConsumerID: consumerID,
// ConsumerRefID: consumerRefID,
// ChannelID: channelID,
// State: state,
// Properties: p,
// CreatedTime: &lt,
// }
// return &c
// }
// func _someCoupons(consumerID string, consumerRefID string, channelID string, couponTypeID string) []*Coupon {
// count := r.Intn(10) + 1
// cs := make([]*Coupon, 0, count)
// for i := 0; i < count; i++ {
// state := r.Intn(int(SUnknown))
// var p map[string]interface{}
// p = make(map[string]interface{}, 1)
// p["the_key"] = "the value"
// cs = append(cs, _aCoupon(consumerID, consumerRefID, channelID, couponTypeID, State(state), p))
// }
// return cs
// }
// func _aTransaction(actorID string, couponID string, tt TransType, extraInfo string) *Transaction {
// var t = Transaction{
// ID: uuid.New().String(),
// CouponID: couponID,
// ActorID: actorID,
// TransType: tt,
// ExtraInfo: extraInfo,
// CreatedTime: time.Now().Local(),
// }
// return &t
// }
// func _someTransaction(actorID string, couponID string, extraInfo string) []*Transaction {
// count := r.Intn(10) + 1
// ts := make([]*Transaction, 0, count)
// for i := 0; i < count; i++ {
// tt := r.Intn(int(TTUnknownTransaction))
// ts = append(ts, _aTransaction(actorID, couponID, TransType(tt), extraInfo))
// }
// return ts
// }
// func _aRequester(userID string, roles []string, brand string) *base.Requester {
// var requester base.Requester
// requester.UserID = userID
// requester.Roles = map[string]([]string){
// "roles": roles,
// }
// requester.Brand = brand
// return &requester
// }
func _validateCouponWithTypeA(c *httpexpect.Object, cid string, cref string, channelID string, state int) {
__validateBasicCoupon(c, cid, cref, channelID, state)
c.ContainsKey("CouponTypeID").ValueEqual("CouponTypeID", typeA)
ps := c.ContainsKey("Properties").Value("Properties").Object()
bind := ps.ContainsKey("binding_rule_properties").Value("binding_rule_properties").Object()
nature := bind.ContainsKey("REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR").Value("REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR").Object()
nature.ContainsKey("unit").ValueEqual("unit", "MONTH")
nature.ContainsKey("endInAdvance").ValueEqual("endInAdvance", 0)
rdtimes := bind.ContainsKey("REDEEM_TIMES").Value("REDEEM_TIMES").Object()
rdtimes.ContainsKey("times").ValueEqual("times", 1)
}
func _validateCouponWithTypeB(c *httpexpect.Object, cid string, cref string, channelID string, state int) {
__validateBasicCoupon(c, cid, cref, channelID, state)
c.ContainsKey("CouponTypeID").ValueEqual("CouponTypeID", typeB)
// ps := c.ContainsKey("Properties").Value("Properties").Object()
// bind := ps.ContainsKey("binding_rule_properties").Value("binding_rule_properties").Object()
// nature := bind.ContainsKey("REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR").Value("REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR").Object()
// nature.ContainsKey("unit").ValueEqual("unit", "MONTH")
// nature.ContainsKey("endInAdvance").ValueEqual("endInAdvance", 0)
// rdtimes := bind.ContainsKey("REDEEM_TIMES").Value("REDEEM_TIMES").Object()
// rdtimes.ContainsKey("times").ValueEqual("times", 1)
}
func __validateBasicCoupon(c *httpexpect.Object, cid string, cref string, channelID string, state int) {
c.ContainsKey("ID").NotEmpty()
c.ContainsKey("ConsumerID").ValueEqual("ConsumerID", cid)
c.ContainsKey("ConsumerRefID").ValueEqual("ConsumerRefID", cref)
c.ContainsKey("ChannelID").ValueEqual("ChannelID", channelID)
c.ContainsKey("State").ValueEqual("State", state)
c.ContainsKey("CreatedTime")
}
func _post1CouponWithToken(e *httpexpect.Expect, u string, uref string, t string, channelID string, statusCode int, token string) (*httpexpect.Object, string) {
r := e.POST("/api/coupons/").WithHeader("Authorization", token).
WithForm(map[string]interface{}{
"consumerIDs": u,
"couponTypeID": t,
"consumerRefIDs": uref,
"channelID": channelID,
}).Expect().Status(statusCode)
var arr *httpexpect.Array
if http.StatusOK == r.Raw().StatusCode {
arr = r.JSON().Array()
} else {
return nil, ""
}
arr.Length().Equal(1)
o := arr.Element(0).Object()
return o, o.Value("ID").String().Raw()
}
func _post1Coupon(e *httpexpect.Expect, u string, uref string, t string, channelID string, statusCode int) (*httpexpect.Object, string) {
return _post1CouponWithToken(e, u, uref, t, channelID, statusCode, jwtyhl5000d)
}
func _post2Coupons(e *httpexpect.Expect, u1 string, u2 string, u1ref string, u2ref string, t string, channelID string) (*httpexpect.Object, *httpexpect.Object, string, string) {
arr := e.POST("/api/coupons/").WithHeader("Authorization", jwtyhl5000d).
WithForm(map[string]interface{}{
"consumerIDs": strings.Join([]string{u1, u2}, ","),
"couponTypeID": t,
"consumerRefIDs": strings.Join([]string{u1ref, u2ref}, ","),
"channelID": channelID,
}).
Expect().Status(http.StatusOK).
JSON().Array()
arr.Length().Equal(2)
var c1, c2 *httpexpect.Object
if arr.Element(0).Object().Value("ConsumerID").String().Raw() == u1 {
c1 = arr.Element(0).Object()
c2 = arr.Element(1).Object()
} else {
c1 = arr.Element(1).Object()
c2 = arr.Element(0).Object()
}
return c1, c2, c1.Value("ID").String().Raw(), c2.Value("ID").String().Raw()
}
func _getCouponByID(e *httpexpect.Expect, cid string) *httpexpect.Object {
return e.GET(strings.Join([]string{"/api/coupons/", cid}, "")).WithHeader("Authorization", jwtyhl5000d).
Expect().Status(http.StatusOK).
JSON().Object()
}
func _getCoupons(e *httpexpect.Expect, u string) *httpexpect.Array {
arr := e.GET("/api/coupons/").WithHeader("Authorization", jwtyhl5000d).
WithHeader("consumerID", u).
Expect().Status(http.StatusOK).
JSON().Array()
return arr
}
func _redeemCouponByID(e *httpexpect.Expect, u string, cid string, extraInfo string, statusCode int) {
e.POST("/api/redemptions").WithHeader("Authorization", jwtyhl5000d).
WithForm(map[string]interface{}{
"consumerIDs": u,
"couponID": cid,
"extraInfo": extraInfo,
}).Expect().Status(statusCode)
// v := r.JSON()
// fmt.Println(v.String().Raw())
}
// func _prepareARandomCouponInDB(consumerID string, consumerRefID string, channelID string, couponType string, propties string) *Coupon {
// state := r.Intn(int(SUnknown))
// var p map[string]interface{}
// p = make(map[string]interface{}, 1)
// p["the_key"] = "the value"
// c := _aCoupon(consumerID, consumerRefID, channelID, couponType, State(state), p)
// sql := fmt.Sprintf(`INSERT INTO coupons VALUES ("%s","%s","%s","%s","%s",%d,"%s","%s")`, c.ID, c.CouponTypeID, c.ConsumerID, c.ConsumerRefID, c.ChannelID, c.State, propties, c.CreatedTime)
// _, _ = dbConnection.Exec(sql)
// return c
// }

View File

@ -0,0 +1,3 @@
vendor
web/**/node_modules
dump.rdb

View File

@ -0,0 +1,5 @@
{
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"vetur.format.defaultFormatter.js": "vscode-typescript",
"vetur.format.defaultFormatter.ts": "vscode-typescript"
}

View File

@ -0,0 +1,182 @@
package main
import (
"database/sql"
"log"
"net/http"
"strings"
"sync"
"loreal.com/dit/endpoint"
"loreal.com/dit/middlewares"
"loreal.com/dit/module"
"loreal.com/dit/module/modules/root"
"loreal.com/dit/utils"
"loreal.com/dit/utils/task"
"github.com/robfig/cron"
)
//App - data struct for App & configuration file
type App struct {
Name string
Description string
Config *Configuration
Root *root.Module
Endpoints map[string]EndpointEntry
MessageHandlers map[string]func(*module.Message) bool
AuthProvider middlewares.RoleVerifier
WebTokenAuthProvider middlewares.WebTokenVerifier
Scheduler *cron.Cron
TaskManager *task.Manager
wg *sync.WaitGroup
mutex *sync.RWMutex
Runtime map[string]*RuntimeEnv
}
//RuntimeEnv - runtime env
type RuntimeEnv struct {
Config *Env
stmts map[string]*sql.Stmt
db *sql.DB
KVStore map[string]interface{}
mutex *sync.RWMutex
}
//Get - get value from kvstore in memory
func (rt *RuntimeEnv) Get(key string) (value interface{}, ok bool) {
rt.mutex.RLock()
defer rt.mutex.RUnlock()
value, ok = rt.KVStore[key]
return
}
//Retrive - get value from kvstore in memory, and delete it
func (rt *RuntimeEnv) Retrive(key string) (value interface{}, ok bool) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
value, ok = rt.KVStore[key]
if ok {
delete(rt.KVStore, key)
}
return
}
//Set - set value to kvstore in memory
func (rt *RuntimeEnv) Set(key string, value interface{}) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
rt.KVStore[key] = value
}
//EndpointEntry - endpoint registry entry
type EndpointEntry struct {
Handler func(http.ResponseWriter, *http.Request)
Middlewares []endpoint.ServerMiddleware
}
//NewApp - create new app
func NewApp(name, description string, config *Configuration) *App {
if config == nil {
log.Println("Missing configuration data")
return nil
}
endpoint.SetPrometheus(strings.Replace(name, "-", "_", -1))
app := &App{
Name: name,
Description: description,
Config: config,
Root: root.NewModule(name, description, config.Prefix),
Endpoints: make(map[string]EndpointEntry, 0),
MessageHandlers: make(map[string]func(*module.Message) bool, 0),
Scheduler: cron.New(),
wg: &sync.WaitGroup{},
mutex: &sync.RWMutex{},
Runtime: make(map[string]*RuntimeEnv),
}
app.TaskManager = task.NewManager(app, 100)
return app
}
//Init - app initialization
func (a *App) Init() {
if a.Config != nil {
a.Config.fixPrefix()
for _, env := range a.Config.Envs {
utils.MakeFolder(env.DataFolder)
a.Runtime[env.Name] = &RuntimeEnv{
Config: env,
KVStore: make(map[string]interface{}, 1024),
mutex: &sync.RWMutex{},
}
}
a.InitDB()
}
a.registerEndpoints()
a.registerMessageHandlers()
a.registerTasks()
// utils.LoadOrCreateJSON("./saved_status.json", &a.Status)
a.Root.OnStop = func(p *module.Module) {
a.TaskManager.SendAll("stop")
a.wg.Wait()
}
a.Root.OnDispose = func(p *module.Module) {
for _, env := range a.Runtime {
if env.db != nil {
log.Println("Close sqlite for", env.Config.Name)
env.db.Close()
}
}
// utils.SaveJSON(a.Status, "./saved_status.json")
}
}
//registerEndpoints - Register Endpoints
func (a *App) registerEndpoints() {
a.initEndpoints()
for path, entry := range a.Endpoints {
if entry.Middlewares == nil {
entry.Middlewares = a.getDefaultMiddlewares(path)
}
a.Root.MountingPoints[path] = endpoint.DecorateServer(
endpoint.Impl(entry.Handler),
entry.Middlewares...,
)
}
}
//registerMessageHandlers - Register Message Handlers
func (a *App) registerMessageHandlers() {
a.initMessageHandlers()
for path, handler := range a.MessageHandlers {
a.Root.AddMessageHandler(path, handler)
}
}
//StartScheduler - register and start the scheduled tasks
func (a *App) StartScheduler() {
if a.Scheduler == nil {
a.Scheduler = cron.New()
} else {
a.Scheduler.Stop()
a.Scheduler = cron.New()
}
for _, item := range a.Config.ScheduledTasks {
log.Println("[INFO] - Adding task:", item.Task)
func() {
s := item.Schedule
t := item.Task
a.Scheduler.AddFunc(s, func() {
a.TaskManager.RunTask(t, item.DefaultArgs...)
})
}()
}
a.Scheduler.Start()
}
//ListenAndServe - Start app
func (a *App) ListenAndServe() {
a.Init()
a.StartScheduler()
a.Root.ListenAndServe(a.Config.Address)
}

View File

@ -0,0 +1,72 @@
package main
//GORoutingNumberForWechat - Total GO Routing # for send process
const GORoutingNumberForWechat = 10
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
var cfg = Configuration{
Address: ":1501",
Prefix: "/",
JwtKey: "3efe5d7d2b4c477db53a5fba0a31a6c5",
AppTitle: "客服查询系统",
UpstreamURL: "https://dl-api.lorealchina.com/api/interface/",
UpstreamClientID: "buycool",
UpstreamClientSecret: "7h48!D!M",
UpstreamUserName: "buycoolcs",
UpstreamPassword: "B0^WRnJZCByrJMMk",
Production: false,
Envs: []*Env{
{
Name: "prod",
SqliteDB: "data.db",
DataFolder: "./data/",
},
{
Name: "pp",
SqliteDB: "ppdata.db",
DataFolder: "./data/",
},
},
ScheduledTasks: []*ScheduledTask{
{Schedule: "0 0 0 * * *", Task: "daily-maintenance", DefaultArgs: []string{}},
{Schedule: "0 10 0 * * *", Task: "daily-maintenance-pp", DefaultArgs: []string{}},
},
}

View File

@ -0,0 +1,83 @@
package main
import (
"strings"
)
//Configuration - app configuration
type Configuration struct {
Address string `json:"address,omitempty"`
Prefix string `json:"prefix,omitempty"`
AppTitle string `json:"app-title"`
JwtKey string `json:"jwt-key,omitempty"`
UpstreamURL string `json:"upstream-url"`
UpstreamClientID string `json:"upstream-client-id"`
UpstreamClientSecret string `json:"upstream-client-secret"`
UpstreamUserName string `json:"upstream-username"`
UpstreamPassword string `json:"upstream-password"`
Production bool `json:"production,omitempty"`
Envs []*Env `json:"envs,omitempty"`
ScheduledTasks []*ScheduledTask `json:"scheduled-tasks,omitempty"`
}
//Env - env configuration
type Env struct {
Name string `json:"name,omitempty"`
SqliteDB string `json:"sqlite-db,omitempty"`
DataFolder string `json:"data,omitempty"`
}
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
//ScheduledTask - Scheduled Task
type ScheduledTask struct {
Schedule string `json:"schedule,omitempty"`
Task string `json:"task,omitempty"`
DefaultArgs []string `json:"default-args,omitempty"`
}
func (c *Configuration) fixPrefix() {
if !strings.HasPrefix(c.Prefix, "/") {
c.Prefix = "/" + c.Prefix
}
if !strings.HasSuffix(c.Prefix, "/") {
c.Prefix = c.Prefix + "/"
}
}

View File

@ -0,0 +1,48 @@
package main
import (
"database/sql"
"fmt"
"log"
// _ "github.com/mattn/go-sqlite3"
)
//InitDB - initialized database
func (a *App) InitDB() {
//init database tables
sqlStmts := []string{
//PV计数
`CREATE TABLE IF NOT EXISTS Visit (
UserID INTEGER DEFAULT 0,
PageID TEXT DEFAULT '',
Scene TEXT DEFAULT '',
State TEXT INTEGER DEFAULT 0,
PV INTEGER DEFAULT 0,
CreateAt DATETIME,
Recent DATETIME
);`,
"CREATE INDEX IF NOT EXISTS idxVisitUserID ON Visit(UserID);",
"CREATE INDEX IF NOT EXISTS idxVisitPageID ON Visit(PageID);",
"CREATE INDEX IF NOT EXISTS idxVisitScene ON Visit(Scene);",
"CREATE INDEX IF NOT EXISTS idxVisitState ON Visit(State);",
"CREATE INDEX IF NOT EXISTS idxVisitCreateAt ON Visit(CreateAt);",
}
var err error
for _, env := range a.Runtime {
env.db, err = sql.Open("sqlite3", fmt.Sprintf("%s%s?cache=shared&mode=rwc", env.Config.DataFolder, env.Config.SqliteDB))
if err != nil {
log.Fatal(err)
}
log.Printf("[INFO] - Initialization DB for [%s]...\n", env.Config.Name)
for _, sqlStmt := range sqlStmts {
_, err := env.db.Exec(sqlStmt)
if err != nil {
log.Printf("[ERR] - [InitDB] %q: %s\n", err, sqlStmt)
return
}
}
env.stmts = make(map[string]*sql.Stmt, 0)
log.Printf("[INFO] - DB for [%s] ready!\n", env.Config.Name)
}
}

View File

@ -0,0 +1,149 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
//debugHandler - vehicle plate management
//endpoint: debug
//method: GET
func (a *App) debugHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
q := r.URL.Query()
module := sanitizePolicy.Sanitize(q.Get("module"))
if module == "" {
module = "*"
}
DEBUG = "" != sanitizePolicy.Sanitize(q.Get("debug"))
INFOLEVEL, _ = strconv.Atoi(sanitizePolicy.Sanitize(q.Get("level")))
LOGLEVEL, _ = strconv.Atoi(sanitizePolicy.Sanitize(q.Get("log-level")))
var result struct {
Module string `json:"module"`
DebugFlag bool `json:"debug-flag"`
InfoLevel int `json:"info-level"`
LogLevel int `json:"log-level"`
}
switch module {
case "*":
}
result.Module = module
result.DebugFlag = DEBUG
result.InfoLevel = INFOLEVEL
result.LogLevel = LOGLEVEL
outputJSON(w, result)
default:
outputJSON(w, map[string]interface{}{
"errcode": -100,
"errmsg": "Method not acceptable",
})
}
}
//feUpgradeHandler - upgrade fe
//endpoint: maintenance/fe/upgrade
//method: GET
func (a *App) feUpgradeHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
return
}
q := r.URL.Query()
_ = q
username := sanitizePolicy.Sanitize(q.Get("u"))
password := q.Get("p")
q.Encode()
var resp struct {
ErrCode int `json:"errcode,omitempty"`
ErrMessage string `json:"errmsg,omitempty"`
Data string `json:"data,omitempty"`
}
const feFolder = "./fe/"
var err error
var strPullURL string
if strPullURL, err = getGitURL(feFolder); err != nil {
log.Println(err)
}
log.Println(strPullURL)
if strPullURL == "" {
resp.Data = "empty pull url"
outputJSON(w, resp)
return
}
buffer := bytes.NewBuffer(nil)
if pullURL, err := url.Parse(strPullURL); err == nil {
pullURL.User = url.UserPassword(username, password)
strPullURL = pullURL.String()
resp.Data += pullURL.String()
}
if err := runShellCmd(nil, buffer, buffer, feFolder, "git", "pull", strPullURL); err != nil {
logError(err, "git pull")
}
if err := runShellCmd(nil, buffer, buffer, feFolder, "npm", "run", "build"); err != nil {
logError(err, "git pull")
}
resp.Data = buffer.String()
outputText(w, buffer.Bytes())
}
func runShellCmd(stdin io.Reader, stdout, stderr io.Writer, workingDir, cmd string, args ...string) error {
di, err := os.Stat(workingDir)
if err != nil {
return err
}
if !di.IsDir() {
return fmt.Errorf("invalid working dir")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
defer cancel()
c := exec.CommandContext(ctx, cmd, args...)
c.Stdin = stdin
c.Stdout = stdout
c.Stderr = stderr
c.Dir = workingDir
if err := c.Run(); err != nil {
log.Println("[ERR] - run", err)
}
return nil
}
func getGitURL(path string) (string, error) {
fi, err := os.Stat(path + ".git/")
if os.IsNotExist(err) || !fi.IsDir() {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*60)
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "--push", "origin")
cmd.Dir = path
rc, err := cmd.StdoutPipe()
if err != nil {
cancel()
log.Println(err)
return "", err
}
go func(cancel context.CancelFunc) {
if err := cmd.Run(); err != nil {
log.Println("[ERR] - run", err)
}
defer cancel()
}(cancel)
data, err := ioutil.ReadAll(rc)
if err := rc.Close(); err != nil {
return strings.TrimRight(string(data), "\r\n"), err
}
return strings.TrimRight(string(data), "\r\n"), nil
}

View File

@ -0,0 +1,110 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
)
/* 以下为具体 Endpoint 实现代码 */
//kvstoreHandler - get value from kvstore in runtime
//endpoint: /api/kvstore
//method: GET
func (a *App) gatewayHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
outputJSON(w, APIStatus{
ErrCode: -1,
ErrMessage: "not acceptable",
})
return
}
if err := r.ParseForm(); err != nil {
log.Printf("[ERR] - [gatewayHandler][ParseForm], err: %v", err)
}
path := sanitizePolicy.Sanitize(r.PostFormValue("path"))
r.PostForm.Del("path")
payload := map[string]interface{}{}
//prepare payload
for k, v := range r.PostForm {
if v == nil || len(v) == 0 {
continue
}
value := sanitizePolicy.Sanitize(v[0])
r.PostForm.Set(k, value)
payload[k] = value
}
bodyBuffer := bytes.NewBuffer(nil)
enc := json.NewEncoder(bodyBuffer)
if err := enc.Encode(payload); err != nil {
outputJSON(w, APIStatus{
ErrCode: -2,
ErrMessage: "500",
})
return
}
req, err := http.NewRequest(http.MethodPost, a.Config.UpstreamURL+path, bytes.NewReader(bodyBuffer.Bytes()))
if err != nil {
outputJSON(w, APIStatus{
ErrCode: -3,
ErrMessage: "500",
})
return
}
account := a.getAPIAccount()
req.Header.Add("Content-Type", "application/json;charset=utf-8")
accessToken := account.GetToken(false)
retry:
if accessToken == "" {
log.Println("[ERR] - [gatewayHandler] Cannot get token")
outputJSON(w, APIStatus{
ErrCode: -4,
ErrMessage: "500",
})
return
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
resp, err := httpClient.Do(req)
if err != nil {
log.Println("[ERR] - [gatewayHandler] http.do err:", err)
outputJSON(w, APIStatus{
ErrCode: -5,
ErrMessage: "502",
})
return
}
defer resp.Body.Close()
bodyObject := map[string]interface{}{}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&bodyObject); err != nil {
outputJSON(w, APIStatus{
ErrCode: -6,
ErrMessage: "500",
})
return
}
if errCode, ok := bodyObject["error"]; ok {
strErr, _ := errCode.(string)
switch strings.ToLower(strErr) {
case "invalid_token":
accessToken = account.GetToken(true)
goto retry
case "not found":
outputJSON(w, APIStatus{
ErrCode: -7,
ErrMessage: "404",
})
return
default:
msg, _ := bodyObject["error_description"]
log.Printf("[ERR] - [interface][wg] errcode: %s, msg: %v", strErr, msg)
}
}
//log.Println("[OUTPUT] - ", bodyObject)
outputJSON(w, bodyObject)
}

View File

@ -0,0 +1,302 @@
package main
import (
"database/sql"
"html/template"
"log"
"net/http"
"strconv"
"loreal.com/dit/endpoint"
"loreal.com/dit/middlewares"
"loreal.com/dit/cmd/ceh-cs-portal/restful"
"github.com/microcosm-cc/bluemonday"
)
// var seededRand *rand.Rand
var sanitizePolicy *bluemonday.Policy
var errorTemplate *template.Template
func init() {
// seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
sanitizePolicy = bluemonday.UGCPolicy()
var err error
errorTemplate, _ = template.ParseFiles("./template/error.tpl")
if err != nil {
log.Panic("[ERR] - Parsing error template", err)
}
}
func brandFilter(r *http.Request, item *map[string]interface{}) bool {
roles := r.Header.Get("roles")
if roles == "admin" {
return false
}
loginBrand := r.Header.Get("brand")
if loginBrand == "" {
return false
}
targetBrand, _ := ((*item)["brand"]).(*string)
return *targetBrand != "" && *targetBrand != loginBrand
}
func (a *App) initEndpoints() {
rt := a.getRuntime("prod")
a.Endpoints = map[string]EndpointEntry{
"api/kvstore": {Handler: a.kvstoreHandler, Middlewares: a.noAuthMiddlewares("api/kvstore")},
"api/visit": {Handler: a.pvHandler},
"error": {Handler: a.errorHandler, Middlewares: a.noAuthMiddlewares("error")},
"debug": {Handler: a.debugHandler},
"maintenance/fe/upgrade": {Handler: a.feUpgradeHandler},
"api/gw": {Handler: a.gatewayHandler},
"api/brand/": {
Handler: restful.NewHandler(
"brand",
restful.NewSQLiteAdapter(rt.db,
rt.mutex,
"Brand",
Brand{},
),
).ServeHTTP,
},
// "api/customer/": {
// Handler: restful.NewHandler(
// "customer",
// restful.NewSQLiteAdapter(rt.db,
// rt.mutex,
// "Customer",
// Customer{},
// ),
// ).SetFilter(storeFilter).ServeHTTP,
// },
}
postPrepareDB(rt)
}
//noAuthMiddlewares - middlewares without auth
func (a *App) noAuthMiddlewares(path string) []endpoint.ServerMiddleware {
return []endpoint.ServerMiddleware{
middlewares.NoCache(),
middlewares.ServerInstrumentation(path, endpoint.RequestCounter, endpoint.LatencyHistogram, endpoint.DurationsSummary),
}
}
// //webTokenAuthMiddlewares - middlewares auth by token
// func (a *App) webTokenAuthMiddlewares(path string) []endpoint.ServerMiddleware {
// return []endpoint.ServerMiddleware{
// middlewares.NoCache(),
// middlewares.WebTokenAuth(a.WebTokenAuthProvider),
// middlewares.ServerInstrumentation(path, endpoint.RequestCounter, endpoint.LatencyHistogram, endpoint.DurationsSummary),
// }
// }
//getDefaultMiddlewares - middlewares installed by defaults
func (a *App) getDefaultMiddlewares(path string) []endpoint.ServerMiddleware {
return []endpoint.ServerMiddleware{
middlewares.NoCache(),
middlewares.WebTokenAuth(a.WebTokenAuthProvider),
// middlewares.BasicAuthOrTokenAuthWithRole(a.AuthProvider, "", "user,admin"),
middlewares.ServerInstrumentation(
path,
endpoint.RequestCounter,
endpoint.LatencyHistogram,
endpoint.DurationsSummary,
),
}
}
func (a *App) getEnv(appid string) string {
if appid == "" {
if a.Config.Production {
return "prod"
}
return "pp"
}
if appid == "ceh" {
return "prod"
}
return "pp"
}
/* 以下为具体 Endpoint 实现代码 */
//errorHandler - query error info
//endpoint: error
//method: GET
func (a *App) errorHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "Not Acceptable", http.StatusNotAcceptable)
return
}
q := r.URL.Query()
title := sanitizePolicy.Sanitize(q.Get("title"))
errmsg := sanitizePolicy.Sanitize(q.Get("errmsg"))
if err := errorTemplate.Execute(w, map[string]interface{}{
"title": title,
"errmsg": errmsg,
}); err != nil {
log.Println("[ERR] - errorTemplate error:", err)
http.Error(w, "500", http.StatusInternalServerError)
}
}
/* 以下为具体 Endpoint 实现代码 */
//kvstoreHandler - get value from kvstore in runtime
//endpoint: /api/kvstore
//method: GET
func (a *App) kvstoreHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
outputJSON(w, APIStatus{
ErrCode: -100,
ErrMessage: "Method not acceptable",
})
return
}
q := r.URL.Query()
ticket := q.Get("ticket")
env := a.getEnv(q.Get("appid"))
rt := a.getRuntime(env)
if rt == nil {
outputJSON(w, APIStatus{
ErrCode: -1,
ErrMessage: "invalid appid",
})
return
}
var result struct {
Value interface{} `json:"value"`
}
var ok bool
var v interface{}
v, ok = rt.Retrive(ticket)
if !ok {
outputJSON(w, APIStatus{
ErrCode: -2,
ErrMessage: "invalid ticket",
})
return
}
switch val := v.(type) {
case chan interface{}:
// log.Println("[Hu Bin] - Get Value Chan:", val)
result.Value = <-val
// log.Println("[Hu Bin] - Get Value from Chan:", result.Value)
default:
// log.Println("[Hu Bin] - Get Value:", val)
result.Value = val
}
outputJSON(w, result)
}
//pvHandler - record PV/UV
//endpoint: /api/visit
//method: GET
func (a *App) pvHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
outputJSON(w, map[string]interface{}{
"code": -1,
"msg": "Not support",
})
return
}
q := r.URL.Query()
rt := a.getRuntime(r.PostForm.Get("env"))
if rt == nil {
outputJSON(w, map[string]interface{}{
"code": -2,
"msg": "Invalid APPID",
})
return
}
userid, _ := strconv.ParseInt(sanitizePolicy.Sanitize(r.PostForm.Get("userid")), 10, 64)
pageid := sanitizePolicy.Sanitize(q.Get("pageid"))
scene := sanitizePolicy.Sanitize(q.Get("scene"))
visitState, _ := strconv.Atoi(sanitizePolicy.Sanitize(q.Get("type")))
if err := a.recordPV(
rt,
userid,
pageid,
scene,
visitState,
); err != nil {
log.Println("[ERR] - [EP][api/visit], err:", err)
outputJSON(w, map[string]interface{}{
"code": -3,
"msg": "internal error",
})
return
}
outputJSON(w, map[string]interface{}{
"code": 0,
"msg": "ok",
})
}
//CSV BOM
//file.Write([]byte{0xef, 0xbb, 0xbf})
func outputExcel(w http.ResponseWriter, b []byte, filename string) {
w.Header().Add("Content-Disposition", "attachment; filename="+filename)
//w.Header().Add("Content-Type", "application/vnd.ms-excel")
w.Header().Add("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
// w.Header().Add("Content-Transfer-Encoding", "binary")
w.Write(b)
}
func outputText(w http.ResponseWriter, b []byte) {
w.Header().Add("Content-Type", "text/plain;charset=utf-8")
w.Write(b)
}
func showError(w http.ResponseWriter, r *http.Request, title, message string) {
if err := errorTemplate.Execute(w, map[string]interface{}{
"title": title,
"errmsg": message,
}); err != nil {
log.Println("[ERR] - errorTemplate error:", err)
http.Error(w, "500", http.StatusInternalServerError)
}
}
//postPrepareDB - initialized database after init endpoints
func postPrepareDB(rt *RuntimeEnv) {
//init database tables
sqlStmts := []string{
// `CREATE TRIGGER IF NOT EXISTS insert_fulfill INSERT ON fulfillment
// BEGIN
// UPDATE CustomerOrder SET qtyfulfilled=qtyfulfilled+new.quantity WHERE id=new.orderid;
// END;`,
// `CREATE TRIGGER IF NOT EXISTS delete_fulfill DELETE ON fulfillment
// BEGIN
// UPDATE CustomerOrder SET qtyfulfilled=qtyfulfilled-old.quantity WHERE id=old.orderid;
// END;`,
// `CREATE TRIGGER IF NOT EXISTS before_update_fulfill BEFORE UPDATE ON fulfillment
// BEGIN
// UPDATE CustomerOrder SET qtyfulfilled=qtyfulfilled-old.quantity WHERE id=old.orderid;
// END;`,
// `CREATE TRIGGER IF NOT EXISTS after_update_fulfill AFTER UPDATE ON fulfillment
// BEGIN
// UPDATE CustomerOrder SET qtyfulfilled=qtyfulfilled+new.quantity WHERE id=new.orderid;
// END;`,
// "CREATE UNIQUE INDEX IF NOT EXISTS uidxOpenID ON WxUser(OpenID);",
}
log.Printf("[INFO] - Post Prepare DB for [%s]...\n", rt.Config.Name)
for _, sqlStmt := range sqlStmts {
_, err := rt.db.Exec(sqlStmt)
if err != nil {
log.Printf("[ERR] - [PrepareDB] %q: %s\n", err, sqlStmt)
return
}
}
rt.stmts = make(map[string]*sql.Stmt, 0)
log.Printf("[INFO] - DB for [%s] prepared!\n", rt.Config.Name)
}

View File

@ -0,0 +1,218 @@
package main
import (
"strings"
)
func (a *App) getUserID(
runtime *RuntimeEnv,
UID string,
userID *int64,
) (err error) {
*userID = -1
const stmtNameGet = "GetUserID"
const stmtSQLGet = "SELECT ID FROM User WHERE UID=?;"
stmtGet := a.getStmt(runtime, stmtNameGet)
if stmtGet == nil {
//lazy setup for stmt
if stmtGet, err = a.setStmt(runtime, stmtNameGet, stmtSQLGet); err != nil {
return
}
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
//get customerID
if err = stmtGet.QueryRow(
UID,
).Scan(userID); err != nil {
return err
}
return
}
func (a *App) recordWxUser(
runtime *RuntimeEnv,
openID, nickName, avatar, scene, pageID string,
customerID *int64,
) (err error) {
*customerID = -1
const stmtNameAdd = "AddUser"
const stmtSQLAdd = "INSERT INTO User (OpenID,NickName,Avatar,Scene,CreateAt) VALUES (?,?,?,?,datetime('now','localtime'));"
const stmtNameGet = "GetWxUserID"
const stmtSQLGet = "SELECT ID FROM User WHERE OpenID=?;"
const stmtNameRecord = "RecordVisit"
const stmtSQLRecord = "UPDATE User SET PV=PV+1 WHERE ID=?;"
const stmtNameNewPV = "InsertVisit"
const stmtSQLNewPV = "INSERT INTO Visit (UserID,PageID,Scene,CreateAt,Recent,PV,State) VALUES (?,?,?,datetime('now','localtime'),datetime('now','localtime'),1,1);"
const stmtNamePV = "RecordPV"
const stmtSQLPV = "UPDATE Visit SET PV=PV+1,Recent=datetime('now','localtime'),State=1 WHERE WxUserID=? AND PageID=? AND Scene=?;"
stmtAdd := a.getStmt(runtime, stmtNameAdd)
if stmtAdd == nil {
//lazy setup for stmt
if stmtAdd, err = a.setStmt(runtime, stmtNameAdd, stmtSQLAdd); err != nil {
return
}
}
stmtGet := a.getStmt(runtime, stmtNameGet)
if stmtGet == nil {
//lazy setup for stmt
if stmtGet, err = a.setStmt(runtime, stmtNameGet, stmtSQLGet); err != nil {
return
}
}
stmtRecord := a.getStmt(runtime, stmtNameRecord)
if stmtRecord == nil {
if stmtRecord, err = a.setStmt(runtime, stmtNameRecord, stmtSQLRecord); err != nil {
return
}
}
stmtNewPV := a.getStmt(runtime, stmtNameNewPV)
if stmtNewPV == nil {
if stmtNewPV, err = a.setStmt(runtime, stmtNameNewPV, stmtSQLNewPV); err != nil {
return
}
}
stmtPV := a.getStmt(runtime, stmtNamePV)
if stmtPV == nil {
if stmtPV, err = a.setStmt(runtime, stmtNamePV, stmtSQLPV); err != nil {
return
}
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
tx, err := runtime.db.Begin()
if err != nil {
return err
}
//Add user
stmtAdd = tx.Stmt(stmtAdd)
result, err := stmtAdd.Exec(
openID,
nickName,
avatar,
scene,
)
if err != nil && !strings.HasPrefix(err.Error(), "UNIQUE") {
tx.Rollback()
return err
}
//get customerID
if result == nil {
//find user
stmtGet = tx.Stmt(stmtGet)
if err = stmtGet.QueryRow(
openID,
).Scan(customerID); err != nil {
tx.Rollback()
return err
}
} else {
*customerID, err = result.LastInsertId()
if err != nil {
tx.Rollback()
return err
}
}
//record customer visit
//log.Println("[INFO] - Add user:", *customerID)
stmtRecord = tx.Stmt(stmtRecord)
_, err = stmtRecord.Exec(*customerID)
if err != nil {
tx.Rollback()
return err
}
stmtPV = tx.Stmt(stmtPV)
pvResult, err := stmtPV.Exec(
*customerID,
pageID,
scene,
)
if err != nil {
tx.Rollback()
return err
}
cnt, err := pvResult.RowsAffected()
if err != nil {
tx.Rollback()
return err
}
if cnt > 0 {
tx.Commit()
return
}
stmtNewPV = tx.Stmt(stmtNewPV)
_, err = stmtNewPV.Exec(
*customerID,
pageID,
scene,
)
if err != nil {
tx.Rollback()
return err
}
tx.Commit()
return
}
func (a *App) recordPV(
runtime *RuntimeEnv,
customerID int64,
pageID, scene string,
visitState int,
) (err error) {
const stmtNameNewPV = "InsertVisit1"
const stmtSQLNewPV = "INSERT INTO Visit (CustomerID,PageID,Scene,CreateAt,Recent,PV,State) VALUES (?,?,?,datetime('now','localtime'),datetime('now','localtime'),1,?);"
const stmtNamePV = "UpdatePV"
const stmtSQLPV = "UPDATE Visit SET PV=PV+1,Recent=datetime('now','localtime') WHERE CustomerID=? AND PageID=? AND Scene=? AND State=?;"
stmtPV := a.getStmt(runtime, stmtNamePV)
if stmtPV == nil {
if stmtPV, err = a.setStmt(runtime, stmtNamePV, stmtSQLPV); err != nil {
return
}
}
stmtNewPV := a.getStmt(runtime, stmtNameNewPV)
if stmtNewPV == nil {
if stmtNewPV, err = a.setStmt(runtime, stmtNameNewPV, stmtSQLNewPV); err != nil {
return
}
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
tx, err := runtime.db.Begin()
if err != nil {
return err
}
stmtPV = tx.Stmt(stmtPV)
pvResult, err := stmtPV.Exec(
customerID,
pageID,
scene,
visitState,
)
if err != nil {
tx.Rollback()
return err
}
cnt, err := pvResult.RowsAffected()
if err != nil {
tx.Rollback()
return err
}
if cnt > 0 {
tx.Commit()
return
}
stmtNewPV = tx.Stmt(stmtNewPV)
_, err = stmtNewPV.Exec(
customerID,
pageID,
scene,
visitState,
)
if err != nil {
tx.Rollback()
return err
}
tx.Commit()
return
}

View File

@ -0,0 +1,41 @@
package main
import "strings"
/* jsonBodyfilter - 用于过滤混淆bodyObject里的内容
bodyObject: 存放需混淆的 JSON 对象
mixupFn 混淆函数 如果为nil则会删除相应的键值
keys 需要混淆或过滤的键
*/
func jsonBodyfilter(
bodyObject *map[string]interface{},
mixupFn func(interface{}) interface{},
keys ...string,
) {
for k, v := range *bodyObject {
switch val := v.(type) {
case map[string]interface{}:
jsonBodyfilter(&val, mixupFn, keys...)
default:
for _, hotKey := range keys {
if k == hotKey {
if mixupFn == nil {
delete(*bodyObject, k)
continue
}
(*bodyObject)[k] = mixupFn(val)
}
}
}
}
}
func mixupString(s interface{}) interface{} {
switch value := s.(type) {
case string:
return strings.Repeat("*", len(value))
default:
return value
}
}

View File

@ -0,0 +1,150 @@
package main
import (
"encoding/json"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
//APIAccount - Upstream API Account
var APIAccount *UpstreamAccount
func (a *App) getAPIAccount() *UpstreamAccount {
if APIAccount != nil {
return APIAccount
}
APIAccount = a.NewUpstreamAccount()
return APIAccount
}
//UpstreamTokenStore - token store for upstream system
type UpstreamTokenStore struct {
Scope string `json:"scope,omitempty"`
TokenType string `json:"token_type,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
JTI string `json:"jti,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
RefreshAt time.Time `json:"-"`
}
//Reset - reset store
func (s *UpstreamTokenStore) Reset() {
s.Scope = ""
s.TokenType = ""
s.AccessToken = ""
s.RefreshToken = ""
s.ExpiresIn = 0
s.JTI = ""
s.Error = ""
s.ErrorDescription = ""
}
//UpstreamAccount - account for upstream system
type UpstreamAccount struct {
TokenURL string
ClientID string
ClientSecret string
UserName string
Password string
store *UpstreamTokenStore
mutex *sync.Mutex
}
//NewUpstreamAccount - create upstream account from config
func (a *App) NewUpstreamAccount() *UpstreamAccount {
const oauthPath = "oauth/token"
return &UpstreamAccount{
TokenURL: a.Config.UpstreamURL + oauthPath,
ClientID: a.Config.UpstreamClientID,
ClientSecret: a.Config.UpstreamClientSecret,
UserName: a.Config.UpstreamUserName,
Password: a.Config.UpstreamPassword,
store: &UpstreamTokenStore{},
mutex: &sync.Mutex{},
}
}
//GetToken - get or refresh Token
func (a *UpstreamAccount) GetToken(forced bool) string {
a.mutex.Lock()
defer a.mutex.Unlock()
if a.store.RefreshToken == "" {
return a.getToken()
}
expiresAt := a.store.RefreshAt.Add(time.Second * time.Duration(a.store.ExpiresIn-10))
if !forced && expiresAt.After(time.Now()) {
return a.store.AccessToken
}
token := a.refreshToken()
if token != "" {
return token
}
return a.getToken()
}
func (a *UpstreamAccount) getToken() string {
payload := url.Values{}
payload.Add("grant_type", "password")
payload.Add("username", a.UserName)
payload.Add("password", a.Password)
req, _ := http.NewRequest(http.MethodPost, a.TokenURL, strings.NewReader(payload.Encode()))
req.SetBasicAuth(a.ClientID, a.ClientSecret)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
log.Printf("[ERR] - [UpstreamTokenStore][getToken], http.do err: %v", err)
return ""
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&a.store); err != nil {
log.Printf("[ERR] - [UpstreamTokenStore][getToken], decode err: %v", err)
return ""
}
if a.store.Error != "" {
log.Printf("[ERR] - [UpstreamTokenStore][getToken], token err: %s, %s", a.store.Error, a.store.ErrorDescription)
//a.store.Reset()
return ""
}
a.store.RefreshAt = time.Now()
return a.store.AccessToken
}
func (a *UpstreamAccount) refreshToken() string {
payload := url.Values{}
payload.Add("grant_type", "refresh_token")
payload.Add("refresh_token", a.store.RefreshToken)
req, _ := http.NewRequest(http.MethodPost, a.TokenURL, strings.NewReader(payload.Encode()))
req.SetBasicAuth(a.ClientID, a.ClientSecret)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
log.Printf("[ERR] - [UpstreamTokenStore][refreshToken], http.do err: %v", err)
return ""
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&a.store); err != nil {
log.Printf("[ERR] - [UpstreamTokenStore][refreshToken], decode err: %v", err)
a.store.Reset()
return ""
}
if a.store.Error != "" {
log.Printf("[ERR] - [UpstreamTokenStore][refreshToken], token err: %s, %s", a.store.Error, a.store.ErrorDescription)
a.store.Reset()
return ""
}
a.store.RefreshAt = time.Now()
return a.store.AccessToken
}

View File

@ -0,0 +1,87 @@
package main
import (
"reflect"
"testing"
"loreal.com/dit/utils"
)
func Test_jsonBodyfilter(t *testing.T) {
type args struct {
bodyObject *map[string]interface{}
mixupFn func(interface{}) interface{}
keys []string
}
tests := []struct {
name string
args args
want map[string]interface{}
}{
// TODO: Add test cases.
{
name: "case1",
args: args{
bodyObject: &map[string]interface{}{
"kt": "a",
"k1": map[string]interface{}{
"kt": "aa",
},
"k2": map[string]interface{}{
"k21": map[string]interface{}{
"kt": "aaa",
"kt1": "aaaa",
},
},
},
mixupFn: mixupString,
keys: []string{"kt", "kt1"},
},
want: map[string]interface{}{
"kt": "*",
"k1": map[string]interface{}{
"kt": "**",
},
"k2": map[string]interface{}{
"k21": map[string]interface{}{
"kt": "***",
"kt1": "****",
},
},
},
},
{
name: "case2",
args: args{
bodyObject: &map[string]interface{}{
"kt": "a",
"k1": map[string]interface{}{
"kt": "aa",
},
"k2": map[string]interface{}{
"k21": map[string]interface{}{
"kt": "aaa",
"kt1": "aaaa",
},
},
},
mixupFn: nil,
keys: []string{"kt", "kt1"},
},
want: map[string]interface{}{
"k1": map[string]interface{}{},
"k2": map[string]interface{}{
"k21": map[string]interface{}{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jsonBodyfilter(tt.args.bodyObject, tt.args.mixupFn, tt.args.keys...)
if !reflect.DeepEqual(*tt.args.bodyObject, tt.want) {
t.Error("\n\nwant:\n", utils.MarshalJSON(tt.want, true), "\ngot:\n", utils.MarshalJSON(*tt.args.bodyObject, true))
}
})
}
}

View File

@ -0,0 +1,123 @@
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"net/url"
"os"
"strings"
)
//DEBUG - whether in debug mode
var DEBUG bool
//INFOLEVEL - info level for debug mode
var INFOLEVEL int
//LOGLEVEL - info level for logs
var LOGLEVEL int
func init() {
if os.Getenv("EV_DEBUG") != "" {
DEBUG = true
}
INFOLEVEL = 1
LOGLEVEL = 1
}
func retry(count int, fn func() error) error {
total := count
retry:
err := fn()
if err != nil {
count--
log.Println("[INFO] - Retry: ", total-count)
if count > 0 {
goto retry
}
}
return err
}
func parseState(state string) map[string]string {
result := make(map[string]string, 2)
var err error
state, err = url.PathUnescape(state)
if err != nil {
log.Println("[ERR] - parseState", err)
return result
}
if DEBUG {
log.Println("[DEBUG] - PathUnescape state:", state)
}
states := strings.Split(state, ";")
for _, kv := range states {
sp := strings.Index(kv, ":")
if sp < 0 {
//empty value
result[kv] = ""
continue
}
result[kv[:sp]] = kv[sp+1:]
}
return result
}
func (a *App) getRuntime(env string) *RuntimeEnv {
runtime, ok := a.Runtime[env]
if !ok {
return nil
}
return runtime
}
//getStmt - get stmt from app safely
func (a *App) getStmt(runtime *RuntimeEnv, name string) *sql.Stmt {
runtime.mutex.RLock()
defer runtime.mutex.RUnlock()
if stmt, ok := runtime.stmts[name]; ok {
return stmt
}
return nil
}
//getStmt - get stmt from app safely
func (a *App) setStmt(runtime *RuntimeEnv, name, query string) (stmt *sql.Stmt, err error) {
stmt, err = runtime.db.Prepare(query)
if err != nil {
logError(err, name)
return nil, err
}
runtime.mutex.Lock()
runtime.stmts[name] = stmt
runtime.mutex.Unlock()
return stmt, nil
}
//outputJSON - output json for http response
func outputJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
enc := json.NewEncoder(w)
if DEBUG {
enc.SetIndent("", " ")
}
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - [outputJSON] JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}
func logError(err error, msg string) {
if err != nil {
log.Printf("[ERR] - %s, err: %v\n", msg, err)
}
}
func debugInfo(source, msg string, level int) {
if DEBUG && INFOLEVEL >= level {
log.Printf("[DEBUG] - [%s]%s\n", source, msg)
}
}

View File

@ -0,0 +1,27 @@
package main
import (
"loreal.com/dit/utils/task"
)
//DailyMaintenance - task to do daily maintenance
func (a *App) DailyMaintenance(t *task.Task) (err error) {
// const stmtName = "dm-clean-vehicle"
// const stmtSQL = "DELETE FROM vehicle_left WHERE enter<=?;"
// env := getEnv(t.Context)
// runtime := a.getRuntime(env)
// if runtime == nil {
// return ErrMissingRuntime
// }
// stmt := a.getStmt(runtime, stmtName)
// if stmt == nil {
// //lazy setup for stmt
// if stmt, err = a.setStmt(runtime, stmtName, stmtSQL); err != nil {
// return err
// }
// }
// runtime.mutex.Lock()
// defer runtime.mutex.Unlock()
// _, err = stmt.Exec(int(time.Now().Add(time.Hour * -168).Unix())) /* 7*24Hours = 168*/
return nil
}

View File

@ -0,0 +1,55 @@
//Smartfix
package main
import (
"flag"
"log"
"math/rand"
"time"
"loreal.com/dit/module/modules/account"
"loreal.com/dit/utils"
)
//Version - generate on build time by makefile
var Version = "v0.1"
//CommitID - generate on build time by makefile
var CommitID = ""
const serviceName = "CS-PORTAL"
const serviceDescription = "CEH-CS-PORTAL"
var app *App
func init() {
}
func main() {
rand.Seed(time.Now().UnixNano())
log.Println("[INFO] -", serviceName, Version+"-"+CommitID)
log.Println("[INFO] -", serviceDescription)
utils.LoadOrCreateJSON("./config/config.json", &cfg) //cfg initialized in config.go
flag.StringVar(&cfg.Address, "addr", cfg.Address, "host:port of the service")
flag.StringVar(&cfg.Prefix, "prefix", cfg.Prefix, "/path/ prefixe to service")
flag.Parse()
//Create Main service
var app = NewApp(serviceName, serviceDescription, &cfg)
uas := account.NewModule("account",
serviceName, /*Token Issuer*/
[]byte(cfg.JwtKey), /*Json Web Token Sign Key*/
10, /*Numbe of faild logins to lock the account */
60*time.Second, /*How long the account will stay locked*/
7200*time.Second, /*How long the token will be valid*/
)
app.Root.Install(
uas,
)
app.AuthProvider = uas
app.WebTokenAuthProvider = uas
app.ListenAndServe()
}

View File

@ -0,0 +1,23 @@
NAME = ceh-cs-portal
BUILDPATH = `go env GOPATH`
OUTPUT = ${BUILDPATH}/bin/loreal.com/${NAME}
PACKAGES = ${BUILDPATH}/src/loreal.com/dit/cmd/${NAME}
GIT_COMMIT = `git rev-parse HEAD | cut -c1-7`
DT = `date +'%Y%m%d-%H%M%S'`
VERSION = V0.5
BUILD_OPTIONS = -ldflags "-X main.Version=$(VERSION) -X main.CommitID=$(DT)"
default:
mkdir -p ${OUTPUT}
go build ${BUILD_OPTIONS} -o ${OUTPUT}/${NAME} ${PACKAGES}
windows:
mkdir -p ${OUTPUT}
go build ${BUILD_OPTIONS} -o ${OUTPUT}/${NAME}.exe ${PACKAGES}
race:
mkdir -p ${OUTPUT}
go build ${BUILD_OPTIONS} -race -o ${OUTPUT}/${NAME}-race ${PACKAGES}
health:
curl http://localhost:1521/health

View File

@ -0,0 +1,25 @@
package main
import (
"log"
"loreal.com/dit/module"
"loreal.com/dit/utils"
)
func (a *App) initMessageHandlers() {
a.MessageHandlers = map[string]func(*module.Message) bool{
"reload": a.reloadMessageHandler,
}
}
//reloadMessageHandler - handle reload message
func (a *App) reloadMessageHandler(msgPtr *module.Message) (handled bool) {
//reload configuration
utils.LoadOrCreateJSON("./config/config.json", &a.Config)
a.Config.fixPrefix()
a.StartScheduler()
log.Println("[INFO] - Configuration reloaded!")
return true
}

View File

@ -0,0 +1,14 @@
package main
import "time"
//Brand - Loreal Brand
type Brand struct {
ID int64 `name:"id" type:"INTEGER"`
Code string `type:"TEXT" index:"asc"`
Name string `type:"TEXT" index:"asc"`
CreateAt time.Time `type:"DATETIME" default:"datetime('now','localtime')"`
Modified time.Time `type:"DATETIME"`
CreateBy string `type:"TEXT"`
ModifiedBy string `type:"TEXT"`
}

View File

@ -0,0 +1,41 @@
package main
import "fmt"
//ErrMissingRuntime - cannot found runtime by name
var ErrMissingRuntime = fmt.Errorf("missing runtime")
//ErrUnfollow - 用户未关注
var ErrUnfollow = fmt.Errorf("用户未关注")
//ErrMsgFailed -消息发送失败
var ErrMsgFailed = fmt.Errorf("消息发送失败")
//MsgState - 消息发送状态
type MsgState int
const (
//MsgStateNew - Initial state
MsgStateNew MsgState = iota
//MsgStateSent - 消息已发送
MsgStateSent
//MsgStateUnfollow - 用户未关注
MsgStateUnfollow
//MsgStateFailed - 发送失败
MsgStateFailed
)
func (ms MsgState) String() string {
switch ms {
case MsgStateNew:
return "消息未发送"
case MsgStateSent:
return "消息已发送"
case MsgStateUnfollow:
return "用户未关注"
case MsgStateFailed:
return "发送失败"
default:
return ""
}
}

View File

@ -0,0 +1,13 @@
package main
//APIStatus - general api result
type APIStatus struct {
ErrCode int `json:"code,omitempty"`
ErrMessage string `json:"msg,omitempty"`
}
//GatewayRequest - requst encoding struct for gateway
type GatewayRequest struct {
Path string `json:"path,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}

View File

@ -0,0 +1,32 @@
package main
import (
"net"
"net/http"
"time"
"loreal.com/dit/endpoint"
"loreal.com/dit/middlewares"
)
var httpClient endpoint.HTTPClient
func init() {
tr := &http.Transport{
Dial: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
Proxy: nil,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 50,
IdleConnTimeout: 30 * time.Second,
}
endpoint.DefaultHTTPClient = &http.Client{Transport: tr}
httpClient = endpoint.DecorateClient(endpoint.DefaultHTTPClient,
middlewares.FaultTolerance(3, 5*time.Second, nil),
//middlewares.ClientInstrumentation("wx-msg-backend", endpoint.RequestCounter, endpoint.LatencyHistogram, endpoint.DurationsSummary),
)
}

View File

@ -0,0 +1,82 @@
package restful
import (
"database/sql"
"fmt"
"log"
"sync"
)
//SQLiteAdapter - SQLite Restful Adapter
type SQLiteAdapter struct {
DB *sql.DB
Mutex *sync.RWMutex
TableName string
tags []FieldTag
sqls map[string]string
stmts map[string]*sql.Stmt
sample interface{}
}
//NewSQLiteAdapter - create new instance from a model template
func NewSQLiteAdapter(db *sql.DB, mutex *sync.RWMutex, tableName string, modelTemplate interface{}) *SQLiteAdapter {
if db == nil {
log.Fatal("[ERR] - [NewSQLiteAdapter] nil db")
}
if mutex == nil {
mutex = &sync.RWMutex{}
}
adapter := &SQLiteAdapter{
DB: db,
Mutex: mutex,
TableName: tableName,
tags: ParseTags(modelTemplate),
sqls: make(map[string]string),
stmts: make(map[string]*sql.Stmt),
sample: modelTemplate,
}
if len(adapter.tags) == 0 {
log.Fatalln("Invalid ModelTemplate:", modelTemplate)
}
adapter.init()
return adapter
}
func (a *SQLiteAdapter) prepareStmt(key, sql string) {
a.Mutex.Lock()
defer a.Mutex.Unlock()
var err error
if a.stmts[key], err = a.DB.Prepare(sql); err != nil {
log.Fatal(err)
}
}
func (a *SQLiteAdapter) init() {
if _, err := a.DB.Exec(a.createTableSQL()); err != nil {
log.Printf("[ERR] - [SQLiteAdapter] Can not create table: [%s], err: %v\n", a.TableName, err)
log.Println("[ERR] - [INFO]", a.createTableSQL())
}
createIdxSqls := a.createIndexSQLs()
for _, cmd := range createIdxSqls {
_, err := a.DB.Exec(cmd)
if err != nil {
log.Printf("[ERR] - [CreateIndex] %v: %s\n", err, cmd)
return
}
}
a.sqls["set"] = a.setSQL(a.getFields(false /*Do not include ID*/))
a.sqls["delete"] = a.deleteSQL()
a.sqls["one"] = a.selectOneSQL()
for key, sql := range a.sqls {
if DEBUG {
log.Printf("[DEBUG] - Prepare [%s]:\n", key)
fmt.Println("------")
fmt.Println(sql)
fmt.Println("------")
fmt.Println()
}
a.prepareStmt(key, sql)
}
log.Printf("[INFO] - Table [%s] prepared\n", a.TableName)
}

View File

@ -0,0 +1,110 @@
package restful
import (
"database/sql"
"log"
"net/url"
"strconv"
)
//Find - implementation of restful model interface
func (a *SQLiteAdapter) Find(query url.Values) (total int64, records []*map[string]interface{}) {
orderby := query.Get("orderby")
if orderby == "" {
orderby = "id"
}
limit, _ := strconv.Atoi(query.Get("limit"))
offset, _ := strconv.Atoi(query.Get("offset"))
paged := limit > 0
keys, values := a.parseParams(query)
countSQL := a.buildCountSQL(keys)
sql := a.buildQuerySQL(keys, orderby, paged)
if DEBUG {
log.Printf("[DEBUG] - [INFO] Count SQL: %s\n", countSQL)
log.Printf("[DEBUG] - [INFO] SQL: %s\n", sql)
}
if paged {
values = append(values, limit, offset)
}
a.Mutex.Lock()
defer a.Mutex.Unlock()
if err := a.DB.QueryRow(countSQL, values...).Scan(&total); err != nil {
log.Printf("[ERR] - [Count SQL]: %s, err: %v\n", countSQL, err)
}
records = a.scanRows(a.DB.Query(sql, values...))
return
}
//FindByID - implementation of restful model interface
func (a *SQLiteAdapter) FindByID(id int64) map[string]interface{} {
buffer := make([]interface{}, 0, len(a.tags))
record := a.newRecord(&buffer)
a.Mutex.Lock()
defer a.Mutex.Unlock()
row := a.stmts["one"].QueryRow(id)
if err := row.Scan(buffer...); err != nil {
log.Println(err)
return nil
}
return record
}
//Insert - implementation of restful model interface
func (a *SQLiteAdapter) Insert(data url.Values) (id int64, err error) {
keys, values := a.parseParams(data)
for i, key := range keys {
if key == "id" {
values[i] = sql.NullInt64{Valid: false}
}
}
r, err := a.DB.Exec(a.insertSQL(keys), values...)
if err != nil {
return -1, err
}
newID, err := r.LastInsertId()
if err != nil {
return -1, err
}
return newID, nil
}
//Set - implementation of restful model interface
func (a *SQLiteAdapter) Set(id int64, data url.Values) error {
keys, values := a.parseParams(data)
values = append(values, id)
sql := a.setSQL(keys)
if DEBUG {
log.Printf("[DEBUG] - [INFO] SQL: %s\n", sql)
}
_, err := a.DB.Exec(a.setSQL(keys), values...)
if err != nil {
return err
}
return nil
}
//Update - implementation of restful model interface
func (a *SQLiteAdapter) Update(data url.Values, where url.Values) (rowsAffected int64, err error) {
keys, values := a.parseParams(data)
whereKeys, whereValues := a.parseParams(where)
values = append(values, whereValues...)
sql := a.updateSQL(keys, whereKeys)
if DEBUG {
log.Printf("[DEBUG] - [INFO] SQL: %s\n", sql)
}
r, err := a.DB.Exec(sql, values...)
if err != nil || r == nil {
return 0, err
}
return r.RowsAffected()
}
//Delete - implementation of restful model interface
func (a *SQLiteAdapter) Delete(id int64) (rowsAffected int64, err error) {
r, err := a.stmts["delete"].Exec(id)
if err != nil || r == nil {
return 0, err
}
return r.RowsAffected()
}

View File

@ -0,0 +1,261 @@
package restful
import (
"reflect"
"strings"
"time"
)
func defaultForSQLNull(tag FieldTag) string {
switch tag.GoType.Kind() {
case reflect.Bool:
return "false"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "-1"
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "0"
case reflect.Float32, reflect.Float64:
return "-1"
case reflect.String:
return "''"
case reflect.Struct:
switch strings.ToLower(tag.DataType) {
case "date", "datetime":
return "'" + time.Time{}.Local().Format(time.RFC3339) + "'"
default:
return "''"
}
// case reflect.Uintptr:
// case reflect.Complex64:
// case reflect.Complex128:
// case reflect.Array:
// case reflect.Chan:
// case reflect.Func:
// case reflect.Interface:
// case reflect.Map:
// case reflect.Ptr:
// case reflect.Slice:
// case reflect.UnsafePointer:
default:
return "''"
}
}
func (a *SQLiteAdapter) createTableSQL() string {
const space = ' '
const tab = '\t'
b := strings.Builder{}
b.WriteString("CREATE TABLE IF NOT EXISTS ")
b.WriteString(a.TableName)
b.WriteString(" (\r\n")
// lastIdx := len(a.tags) - 1
for idx, tag := range a.tags {
if idx > 0 {
b.Write([]byte{',', '\n'})
}
b.WriteByte(tab)
b.WriteString(tag.FieldName)
b.WriteByte(space)
b.WriteString(tag.DataType)
if strings.ToLower(tag.FieldName) == "id" {
b.WriteString(" PRIMARY KEY ASC")
continue
}
b.WriteString(" DEFAULT ")
if tag.Default != "" {
b.WriteByte('(')
b.WriteString(tag.Default)
b.WriteByte(')')
} else {
b.WriteString(defaultForSQLNull(tag))
}
// b.Write(ret)
// if idx == lastIdx {
// b.Write(ret)
// } else {
// b.Write([]byte{',', '\r', '\n'})
// }
}
b.Write([]byte{')', ';'})
return b.String()
}
func (a *SQLiteAdapter) createIndexSQLs() []string {
sqlcmds := make([]string, 0, len(a.tags))
const space = ' '
b := strings.Builder{}
for _, tag := range a.tags {
if tag.Index == "" {
continue
}
b.Reset()
b.WriteString("CREATE INDEX IF NOT EXISTS Idx")
b.WriteString(a.TableName)
b.WriteString(strings.ToTitle(tag.FieldName))
b.WriteString(" ON ")
b.WriteString(a.TableName)
b.WriteByte('(')
b.WriteString(tag.FieldName)
if strings.ToLower(tag.Index) == "desc" {
b.WriteString(" DESC")
}
b.WriteByte(')')
sqlcmds = append(sqlcmds, b.String())
}
return sqlcmds
}
//buildQuerySQL - generate query sql, keys => list of field names in where term
func (a *SQLiteAdapter) buildQuerySQL(keys []string, orderby string, paged bool) string {
var lastIdx int
var lastKeyIdx int
b1 := strings.Builder{}
b1.WriteString("SELECT ")
lastIdx = len(a.tags) - 1
for idx, tag := range a.tags {
b1.WriteString(tag.FieldName)
if idx != lastIdx {
b1.WriteByte(',')
}
}
b1.WriteString(" FROM ")
b1.WriteString(a.TableName)
if len(keys) == 0 {
goto orderby
}
b1.WriteString(" WHERE ")
lastKeyIdx = len(keys) - 1
for idx, key := range keys {
b1.WriteString(key)
b1.Write([]byte{'=', '?'})
if idx != lastKeyIdx {
b1.WriteString(" AND ")
}
}
orderby:
if orderby != "" {
b1.WriteString(" ORDER BY ")
b1.WriteString(orderby)
}
if !paged {
b1.WriteByte(';')
return b1.String()
}
b1.WriteString(" LIMIT ? OFFSET ?;")
return b1.String()
}
//buildCountSQL - generate query sql, keys => list of field names in where term
func (a *SQLiteAdapter) buildCountSQL(keys []string) string {
b1 := strings.Builder{}
b1.WriteString("SELECT count(*) FROM ")
b1.WriteString(a.TableName)
if len(keys) == 0 {
b1.WriteByte(';')
return b1.String()
}
b1.WriteString(" WHERE ")
lastKeyIdx := len(keys) - 1
for idx, key := range keys {
b1.WriteString(key)
b1.Write([]byte{'=', '?'})
if idx != lastKeyIdx {
b1.WriteString(" AND ")
}
}
b1.WriteByte(';')
return b1.String()
}
func (a *SQLiteAdapter) selectOneSQL() string {
b1 := strings.Builder{}
b1.WriteString("SELECT ")
lastIdx := len(a.tags) - 1
for idx, tag := range a.tags {
b1.WriteString(tag.FieldName)
if idx != lastIdx {
b1.WriteByte(',')
}
}
b1.WriteString(" FROM ")
b1.WriteString(a.TableName)
b1.WriteString(" WHERE id=?;")
return b1.String()
}
func (a *SQLiteAdapter) insertSQL(fields []string) string {
b1 := strings.Builder{}
b2 := strings.Builder{}
b1.WriteString("INSERT INTO ")
b2.WriteString(" VALUES (")
b1.WriteString(a.TableName)
b1.WriteString(" (")
lastIdx := len(fields) - 1
for idx, field := range fields {
b1.WriteString(field)
b2.WriteByte('?')
if idx != lastIdx {
b1.WriteByte(',')
b2.WriteByte(',')
}
}
b1.Write([]byte{')'})
b2.Write([]byte{')', ';'})
return b1.String() + b2.String()
}
func (a *SQLiteAdapter) setSQL(fields []string) string {
b1 := strings.Builder{}
b1.WriteString("UPDATE ")
b1.WriteString(a.TableName)
b1.WriteString(" SET ")
l1 := len(fields) - 1
for i, f := range fields {
b1.WriteString(f)
b1.Write([]byte{'=', '?'})
if i != l1 {
b1.WriteByte(',')
}
}
b1.WriteString(" WHERE id=?;")
return b1.String()
}
func (a *SQLiteAdapter) updateSQL(fields, where []string) string {
b1 := strings.Builder{}
b1.WriteString("UPDATE ")
b1.WriteString(a.TableName)
b1.WriteString(" SET ")
l1 := len(fields) - 1
for i, f := range fields {
b1.WriteString(f)
b1.Write([]byte{'=', '?'})
if i != l1 {
b1.WriteByte(',')
}
}
if len(where) == 0 {
b1.WriteByte(';')
return b1.String()
}
l2 := len(where) - 1
b1.WriteString(" WHERE ")
for i, f := range where {
b1.WriteString(f)
b1.Write([]byte{'=', '?'})
if i != l2 {
b1.WriteString(" AND ")
}
}
b1.WriteByte(';')
return b1.String()
}
func (a *SQLiteAdapter) deleteSQL() string {
b1 := strings.Builder{}
b1.WriteString("DELETE FROM ")
b1.WriteString(a.TableName)
b1.WriteString(" WHERE id=?;")
return b1.String()
}

View File

@ -0,0 +1,139 @@
package restful
import (
"database/sql"
"log"
"net/url"
"reflect"
"strconv"
"strings"
"time"
)
func (a *SQLiteAdapter) parseParams(params url.Values) (keys []string, values []interface{}) {
var (
err error
val interface{}
)
keys = make([]string, 0, len(a.tags))
values = make([]interface{}, 0, len(a.tags))
for _, tag := range a.tags {
fname := strings.ToLower(tag.FieldName)
s := params.Get(fname)
if s == "" {
continue
}
switch strings.ToLower(tag.DataType) {
case "int", "integer":
val, err = strconv.Atoi(s)
if err != nil {
log.Printf("[ERR] - [parseParams][int]: s='%s', err=%v\n", s, err)
}
case "real", "float", "double":
val, err = strconv.ParseFloat(s, 64)
if err != nil {
log.Printf("[ERR] - [parseParams][float]: s='%s', err=%v\n", s, err)
}
case "bool", "boolean":
val, err = strconv.ParseBool(s)
if err != nil {
log.Printf("[ERR] - [parseParams][bool]: s='%s', err=%v\n", s, err)
}
case "date", "datetime":
switch s {
case "now":
val = time.Now().Local()
default:
val, err = time.ParseInLocation(time.RFC3339, s, time.Local)
if err != nil {
val, err = time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
log.Printf("[ERR] - [parseParams][datetime]: s='%s', err=%v\n", s, err)
}
}
}
default:
val = s
}
keys = append(keys, fname)
values = append(values, val)
}
return
}
func (a *SQLiteAdapter) getFields(includeID bool) (fields []string) {
fields = make([]string, 0, len(a.tags))
for _, tag := range a.tags {
fname := strings.ToLower(tag.FieldName)
if fname == "id" && !includeID {
continue
}
fields = append(fields, fname)
}
return
}
func (a *SQLiteAdapter) newRecord(buffer *[]interface{}) (record map[string]interface{}) {
record = make(map[string]interface{}, len(a.tags))
*buffer = (*buffer)[:0]
for _, tag := range a.tags {
// var ptr interface{}
// switch tag.GoType.Kind() {
// case reflect.Bool:
// ptr = &sql.NullBool{}
// case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
// ptr = &sql.NullInt64{}
// case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
// ptr = &sql.NullInt64{}
// case reflect.Float32, reflect.Float64:
// ptr = &sql.NullFloat64{}
// case reflect.String:
// ptr = &sql.NullString{}
// case reflect.Struct:
// switch strings.ToLower(tag.DataType) {
// case "date", "datetime":
// ptr = &sql.NullString{}
// default:
// ptr = &sql.NullString{}
// }
// // case reflect.Uintptr:
// // case reflect.Complex64:
// // case reflect.Complex128:
// // case reflect.Array:
// // case reflect.Chan:
// // case reflect.Func:
// // case reflect.Interface:
// // case reflect.Map:
// // case reflect.Ptr:
// // case reflect.Slice:
// // case reflect.UnsafePointer:
// default:
// ptr = reflect.New(tag.GoType).Interface()
// }
ptr := reflect.New(tag.GoType).Interface()
record[strings.ToLower(tag.Name)] = ptr
*buffer = append(*buffer, ptr)
}
return
}
func (a *SQLiteAdapter) scanRows(rows *sql.Rows, err error) (records []*map[string]interface{}) {
records = make([]*map[string]interface{}, 0, 0)
if err != nil {
log.Printf("[ERR] - [scanRows] err: %v\n", err)
return
}
if rows == nil {
return
}
defer rows.Close()
buffer := make([]interface{}, 0, len(a.tags))
for rows.Next() {
record := a.newRecord(&buffer)
if err := rows.Scan(buffer...); err != nil {
log.Printf("[ERR] - [scanRows] err: %v\n", err)
}
records = append(records, &record)
}
return
}

View File

@ -0,0 +1,47 @@
package restful
import (
"database/sql"
"reflect"
"sync"
"testing"
)
func TestNewSQLiteAdapter(t *testing.T) {
type modelTemplateStruct struct {
ID int `name:"id" type:"INTEGER"`
Name string `type:"TEXT"`
Phone string `type:"TEXT"`
Address string `type:"TEXT"`
}
template := modelTemplateStruct{}
type args struct {
db *sql.DB
mutex *sync.RWMutex
tableName string
modelTemplate interface{}
}
tests := []struct {
name string
args args
want *SQLiteAdapter
}{
// TODO: Add test cases.
{
name: "case1",
args: args{
db: nil,
mutex: nil,
tableName: "TestTable",
modelTemplate: template,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := NewSQLiteAdapter(tt.args.db, tt.args.mutex, tt.args.tableName, tt.args.modelTemplate); !reflect.DeepEqual(got, tt.want) {
t.Errorf("NewSQLiteAdapter() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,262 @@
package restful
import (
"log"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
)
// var seededRand *rand.Rand
var sanitizePolicy *bluemonday.Policy
var jsonInvalidMethod = map[string]interface{}{
"errcode": -1,
"message": "Invalid method",
}
var jsonInvalidData = map[string]interface{}{
"errcode": -2,
"message": "Invalid Data",
}
var jsonInvalidID = map[string]interface{}{
"errcode": -3,
"message": "Invalid ID",
}
var jsonOPError = map[string]interface{}{
"errcode": -4,
"message": "Operation Error",
}
var jsonOK = map[string]interface{}{
"message": "OK",
}
func init() {
// seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
sanitizePolicy = bluemonday.UGCPolicy()
}
//Handler - http handler for a restful endpoint
type Handler struct {
Name string /*Endpoint name*/
Model interface{}
Filter func(*http.Request, *map[string]interface{}) bool
}
//NewHandler - create a new instance of RestfulHandler
func NewHandler(name string, model interface{}) *Handler {
handler := &Handler{
Name: name,
Model: model,
}
return handler
}
//SetFilter - set filter
func (h *Handler) SetFilter(filter func(*http.Request, *map[string]interface{}) bool) *Handler {
if filter != nil {
h.Filter = filter
}
return h
}
//sanitize parameters
func sanitize(params *url.Values) {
for key := range *params {
(*params).Set(key, sanitizePolicy.Sanitize((*params).Get(key)))
}
}
func trimURIPrefix(uri string, stopTag string) []string {
params := strings.Split(strings.TrimPrefix(strings.TrimSuffix(uri, "/"), "/"), "/")
last := len(params) - 1
for i := last; i >= 0; i-- {
if params[i] == stopTag {
return params[i+1:]
}
}
return params
}
func parseID(s string) int64 {
id, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return -1
}
return id
}
func (h *Handler) httpGet(w http.ResponseWriter, r *http.Request, id int64) {
m, ok := h.Model.(Querier)
if !ok {
outputGzipJSON(w, jsonInvalidMethod)
return
}
if id != -1 {
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "one",
"payload": m.FindByID(id),
})
return
}
query := r.URL.Query()
sanitize(&query)
total, records := m.Find(query)
if h.Filter == nil {
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "query",
"total": total,
"payload": records,
})
return
}
finalRecords := make([]*map[string]interface{}, 0, len(records))
for _, record := range records {
if !h.Filter(r, record) {
finalRecords = append(finalRecords, record)
}
}
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "query",
"total": len(finalRecords),
"payload": finalRecords,
})
}
func (h *Handler) httpPost(w http.ResponseWriter, r *http.Request, id int64) {
m, ok := h.Model.(Inserter)
if !ok {
outputGzipJSON(w, jsonInvalidMethod)
return
}
if err := r.ParseForm(); err != nil {
log.Println("[ERR] - [RestfulHandler][POST][ParseForm] err:", err)
outputGzipJSON(w, jsonInvalidData)
return
}
sanitize(&r.PostForm)
newID, err := m.Insert(r.PostForm)
if err != nil {
log.Println("[ERR] - [RestfulHandler][POST] err:", err)
outputGzipJSON(w, jsonOPError)
return
}
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "insert",
"id": newID,
})
}
func (h *Handler) httpPut(w http.ResponseWriter, r *http.Request, id int64) {
if err := r.ParseForm(); err != nil {
log.Println("[ERR] - [RestfulHandler][PUT][ParseForm] err:", err)
outputGzipJSON(w, jsonInvalidData)
return
}
sanitize(&r.PostForm)
switch id {
case -1 /*update by query condition*/ :
// m, ok := h.Model.(Updater)
// if !ok {
// outputGzipJSON(w, jsonInvalidMethod)
// return
// }
// query := r.URL.Query()
// sanitize(&query)
// rowsAffected, err := m.Update(r.PostForm, query)
// if err != nil {
// log.Println("[ERR] - [RestfulHandler][PUT-Update] err:", err)
// outputGzipJSON(w, jsonOPError)
// return
// }
// outputGzipJSON(w, map[string]interface{}{
// "message": "ok",
// "method": "update",
// "count": rowsAffected,
// })
outputGzipJSON(w, jsonInvalidID)
return
default /*update by ID*/ :
m, ok := h.Model.(Setter)
if !ok {
outputGzipJSON(w, jsonInvalidMethod)
return
}
if err := m.Set(id, r.PostForm); err != nil {
log.Println("[ERR] - [RestfulHandler][PUT-Set] err:", err)
outputGzipJSON(w, jsonOPError)
return
}
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "set",
})
return
}
}
func (h *Handler) httpDelete(w http.ResponseWriter, r *http.Request, id int64) {
m, ok := h.Model.(Deleter)
if !ok {
outputGzipJSON(w, jsonInvalidMethod)
return
}
switch id {
case -1:
outputGzipJSON(w, jsonInvalidID)
return
}
rowsAffected, err := m.Delete(id)
if err != nil {
log.Println("[ERR] - [RestfulHandler][DELETE] err:", err)
outputGzipJSON(w, jsonOPError)
return
}
outputGzipJSON(w, map[string]interface{}{
"message": "ok",
"method": "delete",
"count": rowsAffected,
})
}
//ServeHTTP - implementation of http.handler
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.Model == nil {
outputGzipJSON(w, jsonInvalidMethod)
return
}
if DEBUG {
log.Println("[DEBUG] - [r.RequestURI]:", r.RequestURI)
}
params := trimURIPrefix(r.RequestURI, h.Name)
var id int64 = -1
if len(params) > 0 {
id = parseID(sanitizePolicy.Sanitize(params[0]))
}
switch r.Method {
case "GET":
h.httpGet(w, r, id)
return
case "POST":
h.httpPost(w, r, id)
return
case "PUT":
h.httpPut(w, r, id)
return
case "DELETE":
h.httpDelete(w, r, id)
return
default:
outputGzipJSON(w, jsonInvalidMethod)
}
}

View File

@ -0,0 +1,67 @@
package restful
import (
"reflect"
"testing"
)
func Test_trimURIPrefix(t *testing.T) {
type args struct {
uri string
stopTag string
}
tests := []struct {
name string
args args
want []string
}{
// TODO: Add test cases.
{
name: "case1",
args: args{
uri: "/crm/api/store/",
stopTag: "store",
},
want: []string{},
},
{
name: "case2",
args: args{
uri: "/crm/api/store/332/",
stopTag: "store",
},
want: []string{"332"},
},
{
name: "case3",
args: args{
uri: "/crm/api/store/332/1222/11",
stopTag: "store",
},
want: []string{"332", "1222", "11"},
},
{
name: "case4",
args: args{
uri: "/crm/api/store",
stopTag: "store",
},
want: []string{},
},
{
name: "case5",
args: args{
uri: "/crm/api/store",
stopTag: "store1",
},
want: []string{"crm", "api", "store"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := trimURIPrefix(tt.args.uri, tt.args.stopTag); !reflect.DeepEqual(got, tt.want) {
t.Errorf("trimURIPrefix() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,3 @@
module restful
go 1.13

View File

@ -0,0 +1,20 @@
package restful
import "os"
//DEBUG - whether in debug mode
var DEBUG bool
//INFOLEVEL - info level for debug mode
var INFOLEVEL int
//LOGLEVEL - info level for logs
var LOGLEVEL int
func init() {
if os.Getenv("EV_DEBUG") != "" {
DEBUG = true
}
INFOLEVEL = 1
LOGLEVEL = 1
}

View File

@ -0,0 +1,31 @@
package restful
import (
"net/url"
)
//Inserter - can insert
type Inserter interface {
Insert(data url.Values) (id int64, err error)
}
//Deleter - can delete
type Deleter interface {
Delete(id int64) (rowsAffected int64, err error)
}
//Querier - can run query
type Querier interface {
Find(query url.Values) (total int64, records []*map[string]interface{})
FindByID(id int64) map[string]interface{}
}
//Setter - can update by ID
type Setter interface {
Set(id int64, data url.Values) error
}
//Updater - can update by condition
type Updater interface {
Update(data url.Values, where url.Values) (rowsAffected int64, err error)
}

View File

@ -0,0 +1,58 @@
package restful
import (
"log"
"reflect"
"strconv"
"strings"
)
//FieldTag - table field tag for restful models
type FieldTag struct {
Name string
FieldName string
DataType string
Default string
Index string
GoType reflect.Type
}
//ParseTags - Parse field tags from source struct
func ParseTags(source interface{}) []FieldTag {
t := reflect.TypeOf(source)
nFields := t.NumField()
r := make([]FieldTag, 0, nFields)
for i := 0; i < nFields; i++ {
field := t.Field(i)
r = append(r, FieldTag{
Name: field.Name,
FieldName: getTagStr(&field, "name", strings.ToLower(field.Name)),
DataType: getTagStr(&field, "type", "text"),
Default: getTagStr(&field, "default", ""),
Index: getTagStr(&field, "index", ""),
GoType: field.Type,
})
}
return r
}
func getTagInt(field *reflect.StructField, tagName string, defaultValue int) int {
v := field.Tag.Get(tagName)
if v == "" {
return defaultValue
}
intValue, err := strconv.Atoi(v)
if err != nil {
log.Fatalln(err)
return defaultValue
}
return intValue
}
func getTagStr(field *reflect.StructField, tagName string, defaultValue string) string {
v := field.Tag.Get(tagName)
if v == "" {
return defaultValue
}
return v
}

View File

@ -0,0 +1,43 @@
package restful
import (
"compress/gzip"
"encoding/json"
"log"
"net/http"
)
//outputJSON - output json for http response
func outputJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
enc := json.NewEncoder(w)
if DEBUG {
enc.SetIndent("", " ")
}
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}
//outputGzipJSON - output json for http response
func outputGzipJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
w.Header().Set("Content-Encoding", "gzip")
// zw, _ := gzip.NewWriterLevel(w, gzip.BestCompression)
zw := gzip.NewWriter(w)
defer func(zw *gzip.Writer) {
zw.Flush()
zw.Close()
}(zw)
enc := json.NewEncoder(zw)
if DEBUG {
enc.SetIndent("", " ")
}
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}

View File

@ -0,0 +1,14 @@
package main
import "loreal.com/dit/utils/task"
func (a *App) registerTasks() {
a.TaskManager.RegisterWithContext("daily-maintenance-pp", "ceh-cs-test", a.dailyMaintenanceTaskHandler, 1)
a.TaskManager.RegisterWithContext("daily-maintenance", "ceh-cs", a.dailyMaintenanceTaskHandler, 1)
}
//dailyMaintenanceTaskHandler - run daily maintenance task
func (a *App) dailyMaintenanceTaskHandler(t *task.Task, args ...string) {
//a.DailyMaintenance(t, task.GetArgs(args, 0))
a.DailyMaintenance(t)
}

View File

@ -0,0 +1,45 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "test coupon@mac",
"type": "go",
"request": "launch",
"mode": "test",
"program": "${workspaceFolder}/coupon",
},
{
"name": "Debug Coupon-service@MAC",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/main.go",
"args": [
"-apitest=1"
]
},
{
"name": "run Coupon-service@MAC",
"type": "go",
"request": "launch",
"mode": "exec",
"program": "${workspaceFolder}/main.go"
},
{
"name": "Debug CCS",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}\\main.go",
"args": [
"-apitest=1"
]
}
]
}

View File

@ -0,0 +1,15 @@
{
"javascript.format.insertSpaceBeforeFunctionParenthesis": true,
"vetur.format.defaultFormatter.js": "vscode-typescript",
"vetur.format.defaultFormatter.ts": "vscode-typescript",
"files.autoSave": "afterDelay",
"window.zoomLevel": 0,
"go.autocompleteUnimportedPackages": true,
"go.gocodePackageLookupMode": "go",
"go.gotoSymbol.includeImports": true,
"go.useCodeSnippetsOnFunctionSuggest": true,
"go.inferGopath": true,
"go.gopath":"C:\\GoPath",
"go.useCodeSnippetsOnFunctionSuggestWithoutType": true,
}

View File

@ -0,0 +1,16 @@
FROM centos:7
LABEL MAINTAINER="yhl10000@gmail.com"
RUN yum -y update && yum clean all
RUN mkdir -p /go && chmod -R 777 /go && \
yum install -y centos-release-scl && \
yum -y install git go-toolset-1.12 && yum clean all
ENV GOPATH=/go \
BASH_ENV=/opt/rh/go-toolset-1.12/enable \
ENV=/opt/rh/go-toolset-1.12/enable
# PROMPT_COMMAND=". /opt/rh/go-toolset-1.12/enable"
WORKDIR /go

View File

@ -0,0 +1,191 @@
package main
import (
"database/sql"
"log"
"net/http"
"strings"
"sync"
"loreal.com/dit/endpoint"
"loreal.com/dit/middlewares"
"loreal.com/dit/module"
"loreal.com/dit/module/modules/root"
"loreal.com/dit/utils"
"loreal.com/dit/utils/task"
"loreal.com/dit/cmd/coupon-service/base"
"github.com/robfig/cron"
"github.com/dgrijalva/jwt-go"
)
//App - data struct for App & configuration file
type App struct {
Name string
Description string
Config *base.Configuration
Root *root.Module
Endpoints map[string]EndpointEntry
MessageHandlers map[string]func(*module.Message) bool
AuthProvider middlewares.RoleVerifier
WebTokenAuthProvider middlewares.WebTokenVerifier
Scheduler *cron.Cron
TaskManager *task.Manager
wg *sync.WaitGroup
mutex *sync.RWMutex
Runtime map[string]*RuntimeEnv
}
//RuntimeEnv - runtime env
type RuntimeEnv struct {
Config *base.Env
stmts map[string]*sql.Stmt
db *sql.DB
KVStore map[string]interface{}
mutex *sync.RWMutex
}
//Get - get value from kvstore in memory
func (rt *RuntimeEnv) Get(key string) (value interface{}, ok bool) {
rt.mutex.RLock()
defer rt.mutex.RUnlock()
value, ok = rt.KVStore[key]
return
}
//Retrive - get value from kvstore in memory, and delete it
func (rt *RuntimeEnv) Retrive(key string) (value interface{}, ok bool) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
value, ok = rt.KVStore[key]
if ok {
delete(rt.KVStore, key)
}
return
}
//Set - set value to kvstore in memory
func (rt *RuntimeEnv) Set(key string, value interface{}) {
rt.mutex.Lock()
defer rt.mutex.Unlock()
rt.KVStore[key] = value
}
//EndpointEntry - endpoint registry entry
type EndpointEntry struct {
Handler func(http.ResponseWriter, *http.Request)
Middlewares []endpoint.ServerMiddleware
}
//NewApp - create new app
func NewApp(name, description string, config *base.Configuration) *App {
if config == nil {
log.Println("Missing configuration data")
return nil
}
endpoint.SetPrometheus(strings.Replace(name, "-", "_", -1))
app := &App{
Name: name,
Description: description,
Config: config,
Root: root.NewModule(name, description, config.Prefix),
Endpoints: make(map[string]EndpointEntry, 0),
MessageHandlers: make(map[string]func(*module.Message) bool, 0),
Scheduler: cron.New(),
wg: &sync.WaitGroup{},
mutex: &sync.RWMutex{},
Runtime: make(map[string]*RuntimeEnv),
}
app.TaskManager = task.NewManager(app, 100)
return app
}
//Init - app initialization
func (a *App) Init() {
if a.Config != nil {
a.Config.FixPrefix()
for _, env := range a.Config.Envs {
utils.MakeFolder(env.DataFolder)
a.Runtime[env.Name] = &RuntimeEnv{
Config: env,
KVStore: make(map[string]interface{}, 1024),
mutex: &sync.RWMutex{},
}
}
a.InitDB()
}
var err error
base.Pubkey, err = jwt.ParseRSAPublicKeyFromPEM([]byte(base.Cfg.AuthPubKey)) //解析公钥
if err != nil {
log.Println("ParseRSAPublicKeyFromPEM:", err.Error())
panic(err)
}
a.registerEndpoints()
a.registerMessageHandlers()
a.registerTasks()
// utils.LoadOrCreateJSON("./saved_status.json", &a.Status)
a.Root.OnStop = func(p *module.Module) {
a.TaskManager.SendAll("stop")
a.wg.Wait()
}
a.Root.OnDispose = func(p *module.Module) {
for _, env := range a.Runtime {
if env.db != nil {
log.Println("Close sqlite for", env.Config.Name)
env.db.Close()
}
}
// utils.SaveJSON(a.Status, "./saved_status.json")
}
}
//registerEndpoints - Register Endpoints
func (a *App) registerEndpoints() {
a.initEndpoints()
for path, entry := range a.Endpoints {
if entry.Middlewares == nil {
entry.Middlewares = a.getDefaultMiddlewares(path)
}
a.Root.MountingPoints[path] = endpoint.DecorateServer(
endpoint.Impl(entry.Handler),
entry.Middlewares...,
)
}
}
//registerMessageHandlers - Register Message Handlers
func (a *App) registerMessageHandlers() {
a.initMessageHandlers()
for path, handler := range a.MessageHandlers {
a.Root.AddMessageHandler(path, handler)
}
}
//StartScheduler - register and start the scheduled tasks
func (a *App) StartScheduler() {
if a.Scheduler == nil {
a.Scheduler = cron.New()
} else {
a.Scheduler.Stop()
a.Scheduler = cron.New()
}
for _, item := range a.Config.ScheduledTasks {
log.Println("[INFO] - Adding task:", item.Task)
func() {
s := item.Schedule
t := item.Task
a.Scheduler.AddFunc(s, func() {
a.TaskManager.RunTask(t, item.DefaultArgs...)
})
}()
}
a.Scheduler.Start()
}
//ListenAndServe - Start app
func (a *App) ListenAndServe() {
// a.Init()
a.StartScheduler()
a.Root.ListenAndServe(a.Config.Address)
}

View File

@ -0,0 +1,34 @@
package base
import (
"fmt"
)
// ErrorWithCode 作为统一返回给调用端的错误结构。
// TODO: 确认下面的注释在godoc里面
type ErrorWithCode struct {
// Code错误编码, 每个code对应一个业务错误。
Code int
// Message错误描述, 可以用于显示给用户。
Message string
}
func (e *ErrorWithCode) Error() string {
return fmt.Sprintf(
`{
"error-code" : %d,
"error-message" : "%s"
}`, e.Code, e.Message)
}
//ErrTokenValidateFailed - 找不到一个规则的校验。
var ErrTokenValidateFailed = ErrorWithCode{
Code: 1500,
Message: "validate token failed",
}
//ErrTokenExpired - 找不到一个规则的校验。
var ErrTokenExpired = ErrorWithCode{
Code: 1501,
Message: "the token is expired",
}

View File

@ -0,0 +1,35 @@
package base
import (
"encoding/json"
"testing"
. "github.com/smartystreets/goconvey/convey"
)
type errorBody struct {
Code int `json:"error-code"`
Msg string `json:"error-message"`
}
const sampleCode int = 100
const sampleMsg string = "Hello world"
func TestBaseerror(t *testing.T) {
Convey("Given an ErrorWithCode object", t, func() {
var e = ErrorWithCode {
Code : sampleCode,
Message : sampleMsg,
}
Convey("The error message Unmarshal by json", func() {
var js = e.Error()
var eb errorBody
err := json.Unmarshal([]byte(js), &eb)
Convey("The value should not be changed", func() {
So(err, ShouldBeNil)
So(eb.Code, ShouldEqual, sampleCode )
So(eb.Msg, ShouldEqual, sampleMsg )
})
})
})
}

View File

@ -0,0 +1,69 @@
package base
import(
"crypto/rsa"
)
//GORoutingNumberForWechat - Total GO Routing # for send process
const GORoutingNumberForWechat = 10
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
var Cfg = Configuration{
Address: ":1503",
Prefix: "/",
JwtKey: "a9ac231b0f2a4f448b8846fd1f57814a",
AuthPubKey: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxWt9gzvtKVZhN9Xt/t1S5xApkSVeKRDbUGbto1NhWIqZCSgY1bmYVDFgnFLGT9tWdZDR4NMYwJxIpdpqjW/w4Q/4H9ummQE57C/AVQ/d4dJrF6MNyz67TL6kmHnrWCNYdHG9I4buTNCUL2y3DRutZ2nhNED/fDFkvQfWjj0ihqa6+Z4ZVTo0i1pX6u/IAjkHSdFRlzluM9EatuSyPo7T83hYqEjwoXkARLjm9jxPBU9jKOcL/1a3pE1QpTisxiQeIsmcbzRH/DPOhbJUwueQ3ux1CGu9RDZ8AX8eZvTrvXF41/b7N4cOi5jUvmV2H02NQh7WLp60Ln/hYmf5+nV5UwIDAQAB\n-----END PUBLIC KEY-----",
AppTitle: "Loreal coupon service",
Production: false,
Envs: []*Env{
{
Name: "prod",
SqliteDB: "data.db",
DataFolder: "./data/",
},
},
ScheduledTasks: []*ScheduledTask{
{Schedule: "0 0 0 * * *", Task: "daily-maintenance", DefaultArgs: []string{}},
{Schedule: "0 10 0 * * *", Task: "daily-maintenance-pp", DefaultArgs: []string{}},
},
}
var Pubkey *rsa.PublicKey

View File

@ -0,0 +1,84 @@
package base
import (
"strings"
)
//Configuration - app configuration
type Configuration struct {
Address string `json:"address,omitempty"`
Prefix string `json:"prefix,omitempty"`
AppTitle string `json:"app-title"`
JwtKey string `json:"jwt-key,omitempty"`
AuthPubKey string `json:"auth-pubkey"`
UpstreamURL string `json:"upstream-url"`
UpstreamClientID string `json:"upstream-client-id"`
UpstreamClientSecret string `json:"upstream-client-secret"`
UpstreamUserName string `json:"upstream-username"`
UpstreamPassword string `json:"upstream-password"`
Production bool `json:"production,omitempty"`
Envs []*Env `json:"envs,omitempty"`
ScheduledTasks []*ScheduledTask `json:"scheduled-tasks,omitempty"`
}
//Env - env configuration
type Env struct {
Name string `json:"name,omitempty"`
SqliteDB string `json:"sqlite-db,omitempty"`
DataFolder string `json:"data,omitempty"`
}
//ScheduledTask - command and parameters to run
/* Schedule Spec:
Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Entry | Description | Equivalent To
----- | ----------- | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st | 0 0 0 1 1 *
@monthly | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly | Run once a week, midnight on Sunday | 0 0 0 * * 0
@daily (or @midnight) | Run once a day, midnight | 0 0 0 * * *
@hourly | Run once an hour, beginning of hour | 0 0 * * * *
***
*** corn example ***:
c := cron.New()
c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
c.AddFunc("@hourly", func() { fmt.Println("Every hour") })
c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
c.Start()
..
// Funcs are invoked in their own goroutine, asynchronously.
...
// Funcs may also be added to a running Cron
c.AddFunc("@daily", func() { fmt.Println("Every day") })
..
// Inspect the cron job entries' next and previous run times.
inspect(c.Entries())
..
c.Stop() // Stop the scheduler (does not stop any jobs already running).
*/
//ScheduledTask - Scheduled Task
type ScheduledTask struct {
Schedule string `json:"schedule,omitempty"`
Task string `json:"task,omitempty"`
DefaultArgs []string `json:"default-args,omitempty"`
}
func (c *Configuration) FixPrefix() {
if !strings.HasPrefix(c.Prefix, "/") {
c.Prefix = "/" + c.Prefix
}
if !strings.HasSuffix(c.Prefix, "/") {
c.Prefix = c.Prefix + "/"
}
}

View File

@ -0,0 +1,142 @@
package base
import (
// "bytes"
"encoding/json"
// "errors"
"fmt"
"log"
"math/rand"
// "mime/multipart"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/dgrijalva/jwt-go"
)
var r *rand.Rand = rand.New(rand.NewSource(time.Now().Unix()))
// IsBlankString 判断是否为空的IDID不能都是空白.
func IsBlankString(str string) bool {
return len(strings.TrimSpace(str)) == 0
}
// IsEmptyString 判断是否为空的字符串.
func IsEmptyString(str string) bool {
return len(str) == 0
}
// IsValidUUID
func IsValidUUID(u string) bool {
_, err := uuid.Parse(u)
return err == nil
}
// SetResponseHeader 一个快捷设置status code 和content type的方法
func SetResponseHeader(w http.ResponseWriter, statusCode int, contentType string) {
w.Header().Set("Content-Type", contentType)
w.WriteHeader(statusCode)
}
// WriteErrorResponse 一个快捷设置包含错误body的response
func WriteErrorResponse(w http.ResponseWriter, statusCode int, contentType string, a interface{}) {
SetResponseHeader(w, statusCode, contentType)
switch vv := a.(type) {
case error: {
fmt.Fprintf(w, vv.Error())
}
case map[string][]error: {
jsonBytes, err := json.Marshal(vv)
if nil != err {
log.Println(err)
fmt.Fprintf(w, err.Error())
}
var str = string(jsonBytes)
fmt.Fprintf(w, str)
}
case []error: {
jsonBytes, err := json.Marshal(vv)
if nil != err {
log.Println(err)
fmt.Fprintf(w, err.Error())
}
var str = string(jsonBytes)
fmt.Fprintf(w, str)
}
}
}
// trimURIPrefix 将一个uri拆分为若干node根据ndoe取得一些动态参数。
func TrimURIPrefix(uri string, stopTag string) []string {
params := strings.Split(strings.TrimPrefix(strings.TrimSuffix(uri, "/"), "/"), "/")
last := len(params) - 1
for i := last; i >= 0; i-- {
if params[i] == stopTag {
return params[i+1:]
}
}
return params
}
//outputJSON - output json for http response
func outputJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json;charset=utf-8")
enc := json.NewEncoder(w)
if err := enc.Encode(data); err != nil {
log.Println("[ERR] - [outputJSON] JSON encode error:", err)
http.Error(w, "500", http.StatusInternalServerError)
return
}
}
// GetMapClaim 获取JWT 里面的map claims
func GetMapClaim(token string) (interface{}) {
if IsBlankString(token) {
return nil
}
ret, b := ParesToken(token, Pubkey)
if nil != b {
return nil
}
return ret
}
// ParesToken 检查toekn
func ParesToken(tokenString string, key interface{}) (interface{}, error) {
token, err := jwt.ParseWithClaims(tokenString, &Requester{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("不支持的加密方式: %v", token.Header["alg"])
}
return key, nil
})
if nil != err {
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors&(jwt.ValidationErrorExpired) != 0 {
return nil, &ErrTokenExpired
}
}
log.Println("解析token失败:", err)
return nil, &ErrTokenValidateFailed
}
// if claims, ok := token.Claims.(jwt.StandardClaims); ok && token.Valid {
if claims, ok := token.Claims.(*Requester); ok && token.Valid {
return claims, nil
} else {
fmt.Println("======pares:", err)
return "", &ErrTokenValidateFailed
}
}
// RandString 生成随机字符串
func RandString(len int) string {
bytes := make([]byte, len)
for i := 0; i < len; i++ {
b := r.Intn(26) + 65
bytes[i] = byte(b)
}
return string(bytes)
}

View File

@ -0,0 +1,69 @@
package base
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestIsBlankString(t *testing.T) {
Convey("Given a hello world string", t, func() {
str := "Hello world"
Convey("The str should not be blank", func() {
So(IsBlankString(str), ShouldBeFalse)
})
})
Convey("Given a string with nothing", t, func() {
str := ""
Convey("The str should be blank", func() {
So(IsBlankString(str), ShouldBeTrue)
})
})
Convey("Given a string with some blank characters", t, func() {
str := " "
Convey("The str should be blank", func() {
So(IsBlankString(str), ShouldBeTrue)
})
})
}
func TestIsEmptyString(t *testing.T) {
Convey("Given a hello world string", t, func() {
str := "Hello world"
Convey("The str should not be empty", func() {
So(IsEmptyString(str), ShouldBeFalse)
})
})
Convey("Given a string with nothing", t, func() {
str := ""
Convey("The str should be empty", func() {
So(IsEmptyString(str), ShouldBeTrue)
})
})
Convey("Given a string with some blank characters", t, func() {
str := " "
Convey("The str should not be empty", func() {
So(IsEmptyString(str), ShouldBeFalse)
})
})
}
func TestIsValidUUID(t *testing.T) {
Convey("Given an UUID string", t, func() {
str := "66e382c4-b859-4e46-9a88-d875fbdaf366"
Convey("The str should be UUID", func() {
So(IsValidUUID(str), ShouldBeTrue)
})
})
Convey("Given a hello world string", t, func() {
str := "Hello world"
Convey("The str should not be UUID", func() {
So(IsValidUUID(str), ShouldBeFalse)
})
})
}

View File

@ -0,0 +1,33 @@
package base
import (
"github.com/chenhg5/collection"
"github.com/dgrijalva/jwt-go"
)
const ROLE_COUPON_ISSUER string = "coupon_issuer"
const ROLE_COUPON_REDEEMER string = "coupon_redeemer"
const ROLE_COUPON_LISTENER string = "coupon_listener"
// Requester 解析数据
type Requester struct { // token里面添加用户信息验证token后可能会用到用户信息
jwt.StandardClaims
UserID string `json:"preferred_username"` //TODO: 能否改成username
Roles map[string]([]string) `json:"realm_access"`
Brand string `json:"brand"`
}
// HasRole 检查请求者是否有某个角色
func (r *Requester) HasRole(role string) bool {
// if !collection.Collect(requester.Roles).Has("roles") {
// return false
// }
roles := r.Roles["roles"]
if nil == roles {
return false
}
if !collection.Collect(roles).Contains(role) {
return false
}
return true
}

View File

@ -0,0 +1,315 @@
package coupon
import (
"database/sql"
"fmt"
"log"
"strings"
"sync"
// "time"
"loreal.com/dit/cmd/coupon-service/base"
// "loreal.com/dit/cmd/coupon-service/rule"
)
var stmtQueryWithType *sql.Stmt
var stmtQueryCoupon *sql.Stmt
var stmtQueryCoupons *sql.Stmt
var stmtInsertCoupon *sql.Stmt
var stmtUpdateCouponState *sql.Stmt
var stmtInsertCouponTransaction *sql.Stmt
var stmtQueryCouponTransactionsWithType *sql.Stmt
var stmtQueryCouponTransactionCountWithType *sql.Stmt
var dbMutex *sync.RWMutex
func dbInit() {
var err error
dbMutex = new(sync.RWMutex)
stmtQueryWithType, err = dbConnection.Prepare("SELECT id, couponTypeID, consumerRefID, channelID, state, properties, createdTime FROM coupons WHERE consumerID = (?) AND couponTypeID = (?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtQueryCoupon, err = dbConnection.Prepare("SELECT id, consumerID, consumerRefID, channelID, couponTypeID, state, properties, createdTime FROM coupons WHERE id = (?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtQueryCoupons, err = dbConnection.Prepare("SELECT id, couponTypeID, consumerRefID, channelID, state, properties, createdTime FROM coupons WHERE consumerID = (?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtInsertCoupon, err = dbConnection.Prepare("INSERT INTO coupons VALUES (?,?,?,?,?,?,?,?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtUpdateCouponState, err = dbConnection.Prepare("UPDATE coupons SET state = (?) WHERE id = (?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtInsertCouponTransaction, err = dbConnection.Prepare("INSERT INTO couponTransactions VALUES (?,?,?,?,?,?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtQueryCouponTransactionsWithType, err = dbConnection.Prepare("SELECT id, actorID, transType, extraInfo, createdTime FROM couponTransactions WHERE couponID = (?) and transType = (?)")
if err != nil {
log.Println(err)
panic(err)
}
stmtQueryCouponTransactionCountWithType, err = dbConnection.Prepare("SELECT count(1) FROM couponTransactions WHERE couponID = (?) and transType = (?)")
if err != nil {
log.Println(err)
panic(err)
}
}
// getCoupons
// TODO: 增加各种过滤条件
func getCoupons(consumerID string, couponTypeID string) ([]*Coupon, error) {
dbMutex.RLock()
defer dbMutex.RUnlock()
var rows *sql.Rows
var err error
if base.IsBlankString(couponTypeID) {
rows, err = stmtQueryCoupons.Query(consumerID)
} else {
rows, err = stmtQueryWithType.Query(consumerID, couponTypeID)
}
if err != nil {
log.Println(err)
return nil, err
}
defer rows.Close()
return getCouponsFromRows(consumerID, rows)
}
func getCouponsFromRows(consumerID string, rows *sql.Rows) ([]*Coupon, error) {
var coupons []*Coupon = make([]*Coupon, 0)
for rows.Next() {
var c Coupon
var pstr string
err := rows.Scan(&c.ID, &c.CouponTypeID, &c.ConsumerRefID, &c.ChannelID, &c.State, &pstr, &c.CreatedTime)
if err != nil {
log.Println(err)
return nil, err
}
c.SetPropertiesFromString(pstr)
c.CreatedTimeToLocal()
c.ConsumerID = consumerID
c.Transactions = make([]*Transaction, 0)
coupons = append(coupons, &c)
}
err := rows.Err()
if err != nil {
log.Println(err)
return nil, err
}
return coupons, nil
}
// getCoupon 获取某一卡券
func getCoupon(couponID string) (*Coupon, error) {
dbMutex.RLock()
defer dbMutex.RUnlock()
row := stmtQueryCoupon.QueryRow(couponID)
var c Coupon
var pstr string
err := row.Scan(&c.ID, &c.ConsumerID, &c.ConsumerRefID, &c.ChannelID, &c.CouponTypeID, &c.State, &pstr, &c.CreatedTime)
if err != nil {
if sql.ErrNoRows == err {
return nil, nil
}
log.Println(err)
return nil, err
}
c.SetPropertiesFromString(pstr)
c.CreatedTimeToLocal()
return &c, nil
}
// CreateCoupon 保存卡券到数据库
func createCoupon(c *Coupon) error {
if nil == c {
return nil
}
dbMutex.Lock()
defer dbMutex.Unlock()
pstr, err := c.GetPropertiesString()
if nil != err {
return err
}
res, err := stmtInsertCoupon.Exec(c.ID, c.CouponTypeID, c.ConsumerID, c.ConsumerRefID, c.ChannelID, c.State, pstr, c.CreatedTime.Unix())
if err != nil {
log.Println(err)
return err
}
log.Println(res)
return nil
}
// CreateCoupon 保存卡券到数据库
func createCoupons(cs []*Coupon) error {
if len(cs) == 0 {
return nil
}
dbMutex.Lock()
defer dbMutex.Unlock()
valueStrings := make([]string, 0, len(cs))
valueArgs := make([]interface{}, 0, len(cs)*8)
for _, c := range cs {
valueStrings = append(valueStrings, "(?,?,?,?,?,?,?,?)")
valueArgs = append(valueArgs, c.ID)
valueArgs = append(valueArgs, c.CouponTypeID)
valueArgs = append(valueArgs, c.ConsumerID)
valueArgs = append(valueArgs, c.ConsumerRefID)
valueArgs = append(valueArgs, c.ChannelID)
valueArgs = append(valueArgs, c.State)
pstr, err := c.GetPropertiesString()
if nil != err {
return err
}
valueArgs = append(valueArgs, pstr)
valueArgs = append(valueArgs, c.CreatedTime.Unix())
}
stmt := fmt.Sprintf("INSERT INTO coupons VALUES %s", strings.Join(valueStrings, ","))
res, err := dbConnection.Exec(stmt, valueArgs...)
if err != nil {
log.Println(err)
return err
}
log.Println(res)
return nil
}
// updateCouponState 更新卡券状态
func updateCouponState(c *Coupon) error {
if nil == c {
return nil
}
dbMutex.Lock()
defer dbMutex.Unlock()
res, err := stmtUpdateCouponState.Exec(c.State, c.ID)
if err != nil {
log.Println(err)
return err
}
log.Println(res)
return nil
}
// CreateCouponTransaction 保存卡券操作log到数据库
func createCouponTransaction(t *Transaction) error {
if nil == t {
return nil
}
dbMutex.Lock()
defer dbMutex.Unlock()
res, err := stmtInsertCouponTransaction.Exec(t.ID, t.CouponID, t.ActorID, t.TransType, t.EncryptExtraInfo(), t.CreatedTime.Unix())
if err != nil {
log.Println(err)
return err
}
log.Println(res)
return nil
}
// createCouponTransactions 保存卡券操作log到数据库
func createCouponTransactions(ts []*Transaction) error {
if len(ts) == 0 {
return nil
}
dbMutex.Lock()
defer dbMutex.Unlock()
valueStrings := make([]string, 0, len(ts))
valueArgs := make([]interface{}, 0, len(ts)*6)
for _, t := range ts {
valueStrings = append(valueStrings, "(?,?,?,?,?,?)")
valueArgs = append(valueArgs, t.ID)
valueArgs = append(valueArgs, t.CouponID)
valueArgs = append(valueArgs, t.ActorID)
valueArgs = append(valueArgs, t.TransType)
valueArgs = append(valueArgs, t.EncryptExtraInfo())
valueArgs = append(valueArgs, t.CreatedTime.Unix())
}
stmt := fmt.Sprintf("INSERT INTO couponTransactions VALUES %s", strings.Join(valueStrings, ","))
res, err := dbConnection.Exec(stmt, valueArgs...)
if err != nil {
log.Println(err)
return err
}
log.Println(res)
return nil
}
// getCouponTransactionsWithType 获取某一卡券某类型业务的记录
func getCouponTransactionsWithType(couponID string, ttype TransType) ([]*Transaction, error) {
dbMutex.RLock()
defer dbMutex.RUnlock()
rows, err := stmtQueryCouponTransactionsWithType.Query(couponID, ttype)
if err != nil {
log.Println(err)
return nil, err
}
defer rows.Close()
var ts []*Transaction = make([]*Transaction, 0)
for rows.Next() {
var t Transaction
var ei string
err := rows.Scan(&t.ID, &t.ActorID, &t.TransType, &ei, &t.CreatedTime)
if err != nil {
log.Println(err)
return nil, err
}
t.CreatedTimeToLocal()
err = t.DecryptExtraInfo(ei)
if err != nil {
log.Println(err)
return nil, err
}
t.CouponID = couponID
ts = append(ts, &t)
}
err = rows.Err()
if err != nil {
log.Println(err)
return nil, err
}
return ts, nil
}
// getCouponTransactionCountWithType 获取某一卡券某一类型业务的次数,比如查询兑换多少次这样的场景
func getCouponTransactionCountWithType(couponID string, ttype TransType) (uint, error) {
dbMutex.RLock()
defer dbMutex.RUnlock()
var count uint
err := stmtQueryCouponTransactionCountWithType.QueryRow(couponID, ttype).Scan(&count)
if err != nil {
if sql.ErrNoRows == err {
return 0, nil
}
log.Println(err)
return 0, err
}
return count, nil
}

View File

@ -0,0 +1,419 @@
package coupon
import (
"fmt"
// "time"
// "reflect"
"testing"
"loreal.com/dit/cmd/coupon-service/base"
// "loreal.com/dit/utils"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
. "github.com/smartystreets/goconvey/convey"
)
func Test_GetCoupons(t *testing.T) {
Convey("Given a coupon in an empty table", t, func() {
c := _prepareARandomCouponInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
Convey("Get the coupon and should contain the coupon just created", func() {
cs, _ := getCoupons(c.ConsumerID, defaultCouponTypeID)
So(len(cs), ShouldEqual, 1)
c2 := cs[0]
So(base.IsValidUUID(c2.ID), ShouldBeTrue)
So(c.ConsumerID, ShouldEqual, c2.ConsumerID)
So(c.ConsumerRefID, ShouldEqual, c2.ConsumerRefID)
So(c.ChannelID, ShouldEqual, c2.ChannelID)
So(defaultCouponTypeID, ShouldEqual, c2.CouponTypeID)
So(c.State, ShouldEqual, c2.State)
})
})
// Convey("Given several coupons for 2 consumers in an empty table", t, func() {
// cs := _prepareSeveralCouponsInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
// _ = _prepareSeveralCouponsInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, false)
// Convey("Get for the first consumer and should contain all the coupons just created for first consumer", func() {
// cs3, _ := getCoupons(cs[0].ConsumerID, defaultCouponTypeID)
// So(len(cs3), ShouldEqual, len(cs))
// c := cs[r.Intn(len(cs))]
// for _, c2 := range cs3 {
// if c.ID == c2.ID {
// So(c.ConsumerID, ShouldEqual, c2.ConsumerID)
// break
// }
// }
// })
// })
// Convey("Given several coupons with different coupon type in an empty table", t, func() {
// consumerID := base.RandString(4)
// cs := _prepareSeveralCouponsInDB(consumerID, base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
// _ = _prepareSeveralCouponsInDB(consumerID, base.RandString(4), base.RandString(4), anotherCouponTypeID, false)
// Convey("Get the coupon with defaultCouponTypeID and only with defaultCouponTypeID", func() {
// cs2, _ := getCoupons(consumerID, defaultCouponTypeID)
// So(len(cs), ShouldEqual, len(cs2))
// c := cs[r.Intn(len(cs))]
// for _, c2 := range cs2 {
// if c.ID == c2.ID {
// So(c.ConsumerID, ShouldEqual, c2.ConsumerID)
// So(c.ConsumerRefID, ShouldEqual, c2.ConsumerRefID)
// So(c.ChannelID, ShouldEqual, c2.ChannelID)
// So(c.CouponTypeID, ShouldEqual, c2.CouponTypeID)
// So(c.State, ShouldEqual, c2.State)
// break
// }
// }
// })
// })
// Convey("Given a coupon in an empty table and will query with wrong input", t, func() {
// c := _prepareARandomCouponInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
// Convey("Get the coupon with wrong type and should contain nothing", func() {
// cs, _ := getCoupons(c.ConsumerID, anotherCouponTypeID)
// So(len(cs), ShouldEqual, 0)
// })
// Convey("Get the coupon with wrong consumerID and should contain nothing", func() {
// wrongConsumerID := "this is not a real type"
// cs, _ := getCoupons(wrongConsumerID, defaultCouponTypeID)
// So(len(cs), ShouldEqual, 0)
// })
// })
}
func Test_GetCoupon(t *testing.T) {
Convey("Given a coupon in an empty table", t, func() {
c := _prepareARandomCouponInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
Convey("get this coupon and should be same with the coupon just created", func() {
c2, _ := getCoupon(c.ID)
So(c.ID, ShouldEqual, c2.ID)
So(c.ConsumerID, ShouldEqual, c2.ConsumerID)
So(c.ConsumerRefID, ShouldEqual, c2.ConsumerRefID)
So(c.ChannelID, ShouldEqual, c2.ChannelID)
So(c.CouponTypeID, ShouldEqual, c2.CouponTypeID)
So(c.State, ShouldEqual, c2.State)
So(c.Properties, ShouldContainKey, "the_key")
So(c.Properties["the_key"], ShouldEqual, "the value")
})
})
Convey("Given several coupons in an empty table", t, func() {
cs := _prepareSeveralCouponsInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
Convey("get the random coupon and should be same with the first coupon just created", func() {
index := r.Intn(len(cs))
c2, _ := getCoupon(cs[index].ID)
So(cs[index].ID, ShouldEqual, c2.ID)
So(cs[index].ConsumerID, ShouldEqual, c2.ConsumerID)
So(cs[index].ConsumerRefID, ShouldEqual, c2.ConsumerRefID)
So(cs[index].ChannelID, ShouldEqual, c2.ChannelID)
So(cs[index].CouponTypeID, ShouldEqual, c2.CouponTypeID)
So(cs[index].State, ShouldEqual, c2.State)
})
})
Convey("Given a truncated table", t, func() {
_, _ = dbConnection.Exec("DELETE FROM coupons")
Convey("get with a non existed coupon id and the coupon should be null", func() {
c, _ := getCoupon("some_coupon_id")
So(c, ShouldBeNil)
})
})
}
func Test_CreateCoupon(t *testing.T) {
Convey("Given a coupon", t, func() {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
p["the_key"] = "the value"
cc := _aCoupon(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, State(state), p)
Convey("Save the coupon to database", func() {
createCoupon(cc)
Convey("The coupon just created can be queried", func() {
s := fmt.Sprintf(`SELECT id, consumerID, consumerRefID, channelID, couponTypeID, state, properties, createdTime FROM coupons WHERE id = '%s'`, cc.ID)
row := dbConnection.QueryRow(s)
var c Coupon
var pstr string
_ = row.Scan(&c.ID, &c.ConsumerID, &c.ConsumerRefID, &c.ChannelID, &c.CouponTypeID, &c.State, &pstr, &c.CreatedTime)
c.SetPropertiesFromString(pstr)
c.CreatedTimeToLocal()
Convey("The coupon queried should be same with original", func() {
So(cc.ID, ShouldEqual, c.ID)
So(cc.ConsumerID, ShouldEqual, c.ConsumerID)
So(cc.ConsumerRefID, ShouldEqual, c.ConsumerRefID)
So(cc.ChannelID, ShouldEqual, c.ChannelID)
So(cc.CouponTypeID, ShouldEqual, c.CouponTypeID)
So(cc.State, ShouldEqual, c.State)
So(cc.Properties, ShouldContainKey, "the_key")
So(cc.Properties["the_key"], ShouldEqual, "the value")
})
})
})
})
Convey("Given nil coupon", t, func() {
err := createCoupon(nil)
Convey("Nothing happened", func() {
So(err, ShouldBeNil)
})
})
}
func Test_CreateCoupons(t *testing.T) {
Convey("Given several coupons", t, func() {
cs := _someCoupons(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID)
Convey("createCoupons save them to database", func() {
_, _ = dbConnection.Exec("DELETE FROM coupons")
createCoupons(cs)
Convey("Saved coupons should same with prepared", func() {
for _, c := range cs {
s := fmt.Sprintf(`SELECT id, consumerID, consumerRefID, channelID, couponTypeID, state, properties, createdTime FROM coupons WHERE id = '%s'`, c.ID)
row := dbConnection.QueryRow(s)
var c2 Coupon
var pstr string
_ = row.Scan(&c2.ID, &c2.ConsumerID, &c2.ConsumerRefID, &c2.ChannelID, &c2.CouponTypeID, &c2.State, &pstr, &c2.CreatedTime)
c2.SetPropertiesFromString(pstr)
So(c.ID, ShouldEqual, c2.ID)
So(c.ConsumerID, ShouldEqual, c2.ConsumerID)
So(c.ConsumerRefID, ShouldEqual, c2.ConsumerRefID)
So(c.ChannelID, ShouldEqual, c2.ChannelID)
So(c.CouponTypeID, ShouldEqual, c2.CouponTypeID)
So(c.State, ShouldEqual, c2.State)
So(c2.Properties, ShouldContainKey, "the_key")
So(c2.Properties["the_key"], ShouldEqual, "the value")
}
})
})
})
Convey("Given empty coupon list", t, func() {
err := createCoupons(make([]*Coupon, 0))
Convey("Nothing happened", func() {
So(err, ShouldBeNil)
})
})
}
func Test_UpdateCouponState(t *testing.T) {
Convey("Given a coupon in an empty table", t, func() {
c := _prepareARandomCouponInDB(base.RandString(4), base.RandString(4), base.RandString(4), defaultCouponTypeID, true)
Convey("Reset the coupon state", func() {
oldState := int(c.State)
c.State = State((oldState + 1) % int(SUnknown))
newState := int(c.State)
So(oldState, ShouldNotEqual, int(c.State))
updateCouponState(c)
Convey("The latest coupon should have diff state with original", func() {
c2, _ := getCoupon(c.ID)
So(c2.State, ShouldEqual, newState)
})
})
})
Convey("Given nil coupon", t, func() {
err := updateCouponState(nil)
Convey("Nothing happened", func() {
So(err, ShouldBeNil)
})
})
}
func Test_CreateCouponTransaction(t *testing.T) {
Convey("Given a coupon transaction", t, func() {
tt := r.Intn(int(TTUnknownTransaction) + 1)
t := _aTransaction(base.RandString(4), uuid.New().String(), TransType(tt), base.RandString(4))
Convey("Save the transaction to database", func() {
createCouponTransaction(t)
Convey("The transaction just created can be queried", func() {
s := fmt.Sprintf(`SELECT id, couponID, actorID, transType, extraInfo, createdTime FROM couponTransactions WHERE id = '%s'`, t.ID)
row := dbConnection.QueryRow(s)
var t2 Transaction
var ei string
_ = row.Scan(&t2.ID, &t2.CouponID, &t2.ActorID, &t2.TransType, &ei, &t2.CreatedTime)
t2.DecryptExtraInfo(ei)
Convey("The transaction queried should be same with original", func() {
So(t.ID, ShouldEqual, t2.ID)
So(t.CouponID, ShouldEqual, t2.CouponID)
So(t.ActorID, ShouldEqual, t2.ActorID)
So(t.TransType, ShouldEqual, t2.TransType)
So(t.ExtraInfo, ShouldEqual, t2.ExtraInfo)
})
})
})
})
Convey("Given nil coupon transaction", t, func() {
err := createCouponTransaction(nil)
Convey("Nothing happened", func() {
So(err, ShouldBeNil)
})
})
}
func Test_CreateCouponTransactions(t *testing.T) {
Convey("Given several coupon transactions", t, func() {
ts := _someTransaction(base.RandString(4), uuid.New().String(), base.RandString(4))
Convey("createCouponTransactions save them to database", func() {
_, _ = dbConnection.Exec("DELETE FROM coupons")
createCouponTransactions(ts)
Convey("Saved coupon transactions should same with prepared", func() {
for _, t := range ts {
s := fmt.Sprintf(`SELECT id, couponID, actorID, transType, extraInfo, createdTime FROM couponTransactions WHERE id = '%s'`, t.ID)
row := dbConnection.QueryRow(s)
var t2 Transaction
var ei string
_ = row.Scan(&t2.ID, &t2.CouponID, &t2.ActorID, &t2.TransType, &ei, &t2.CreatedTime)
t2.DecryptExtraInfo(ei)
So(t.ID, ShouldEqual, t2.ID)
So(t.CouponID, ShouldEqual, t2.CouponID)
So(t.ActorID, ShouldEqual, t2.ActorID)
So(t.TransType, ShouldEqual, t2.TransType)
So(t.ExtraInfo, ShouldEqual, t2.ExtraInfo)
}
})
})
})
Convey("Given empty coupon transaction list", t, func() {
err := createCouponTransactions(make([]*Transaction, 0))
Convey("Nothing happened", func() {
So(err, ShouldBeNil)
})
})
}
func Test_GetCouponTransactionsWithType(t *testing.T) {
Convey("Given a coupon transaction in an empty table", t, func() {
tt := r.Intn(int(TTUnknownTransaction))
t := _prepareARandomCouponTransactionInDB(base.RandString(4), uuid.New().String(), TransType(tt), true)
Convey("getCouponTransactionsWithType can get the coupon transaction", func() {
ts, _ := getCouponTransactionsWithType(t.CouponID, TransType(tt))
Convey("And should contain the coupon transaction just created", func() {
So(len(ts), ShouldEqual, 1)
t2 := ts[0]
So(base.IsValidUUID(t2.ID), ShouldBeTrue)
So(t.CouponID, ShouldEqual, t2.CouponID)
So(t.ActorID, ShouldEqual, t2.ActorID)
So(t.TransType, ShouldEqual, t2.TransType)
})
})
Convey("getCouponTransactionsWithType try to get the coupon transaction with diff trans type", func() {
tt2 := tt + 1%int(TTUnknownTransaction)
ts, _ := getCouponTransactionsWithType(t.CouponID, TransType(tt2))
Convey("And should not contain the coupon just created", func() {
So(len(ts), ShouldEqual, 0)
})
})
Convey("getCouponTransactionsWithType try to get the coupon transaction with wrong coupon id", func() {
ts, _ := getCouponTransactionsWithType(base.RandString(4), TransType(tt))
Convey("And should not contain the coupon just created", func() {
So(len(ts), ShouldEqual, 0)
})
})
})
Convey("Given several coupon transactions with different coupon in an empty table", t, func() {
couponID := uuid.New().String()
tt3 := r.Intn(int(TTUnknownTransaction))
ts := _prepareSeveralCouponTransactionsInDB(base.RandString(4), couponID, TransType(tt3), true)
_ = _prepareSeveralCouponTransactionsInDB(base.RandString(4), uuid.New().String(), TransType(tt3), false)
Convey("getCouponTransactionsWithType only get the coupon transactions with given couponID", func() {
ts2, _ := getCouponTransactionsWithType(couponID, TransType(tt3))
Convey("And should contain the coupon ransactions just created with given couponID", func() {
So(len(ts), ShouldEqual, len(ts2))
t := ts[0]
for _, t2 := range ts2 {
if t.ID == t2.ID {
So(t.ID, ShouldEqual, t2.ID)
So(t.CouponID, ShouldEqual, t2.CouponID)
So(t.ActorID, ShouldEqual, t2.ActorID)
So(t.TransType, ShouldEqual, t2.TransType)
break
}
}
})
})
})
}
func Test_GetCouponTransactionCountWithType(t *testing.T) {
Convey("Given a clean db", t, func() {
dbConnection.Exec("DELETE FROM couponTransactions")
Convey("There is no coupon transactio", func() {
tt := r.Intn(int(TTUnknownTransaction))
count, err := getCouponTransactionCountWithType(base.RandString(4), TransType(tt))
So(0, ShouldEqual, count)
So(err, ShouldBeNil)
})
})
Convey("Given several coupon transactions with different trans type in an empty table", t, func() {
couponID := uuid.New().String()
tt := r.Intn(int(TTUnknownTransaction))
ts := _prepareSeveralCouponTransactionsInDB(base.RandString(4), couponID, TransType(tt), true)
tt2 := (tt + 1) % int(TTUnknownTransaction)
_ = _prepareSeveralCouponTransactionsInDB(base.RandString(4), couponID, TransType(tt2), false)
Convey("getCouponTransactionsWithType only get the coupon transactions with given type", func() {
count, err := getCouponTransactionCountWithType(couponID, TransType(tt))
Convey("And should contain the coupon ransactions just created with given couponID", func() {
So(len(ts), ShouldEqual, count)
So(err, ShouldBeNil)
})
})
})
}
func _prepareSeveralCouponTransactionsInDB(actorID string, couponID string, tt TransType, bTrancate bool) []*Transaction {
count := r.Intn(10) + 1
if bTrancate {
_, _ = dbConnection.Exec("DELETE FROM couponTransactions")
}
ts := make([]*Transaction, 0, count)
for i := 0; i < count; i++ {
ts = append(ts, _prepareARandomCouponTransactionInDB(actorID, couponID, tt, false))
}
return ts
}
func _prepareARandomCouponTransactionInDB(actorID string, couponID string, tt TransType, bTrancate bool) *Transaction {
t := _aTransaction(actorID, couponID, TransType(tt), base.RandString(4))
if bTrancate {
_, _ = dbConnection.Exec("DELETE FROM couponTransactions")
}
s := fmt.Sprintf(`INSERT INTO couponTransactions VALUES ("%s","%s","%s",%d,"%s","%d")`, t.ID, t.CouponID, t.ActorID, t.TransType, t.EncryptExtraInfo(), t.CreatedTime.Unix())
_, e := dbConnection.Exec(s)
if nil != e {
fmt.Println("dbConnection.Exec(s) ==== ", e.Error())
}
return t
}
func _prepareSeveralCouponsInDB(consumerID string, consumerRefID string, channelID string, couponType string, bTrancate bool) []*Coupon {
count := r.Intn(10) + 1
if bTrancate {
_, _ = dbConnection.Exec("DELETE FROM coupons")
}
cs := make([]*Coupon, 0, count)
for i := 0; i < count; i++ {
cs = append(cs, _prepareARandomCouponInDB(consumerID, consumerRefID, channelID, couponType, false))
}
return cs
}
func _prepareARandomCouponInDB(consumerID string, consumerRefID string, channelID string, couponType string, bTrancate bool) *Coupon {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
p["the_key"] = "the value"
c := _aCoupon(consumerID, consumerRefID, channelID, couponType, State(state), p)
if bTrancate {
_, _ = dbConnection.Exec("DELETE FROM coupons")
}
s := fmt.Sprintf(`INSERT INTO coupons VALUES ("%s","%s","%s","%s","%s",%d,"%s",%d)`, c.ID, c.CouponTypeID, c.ConsumerID, c.ConsumerRefID, c.ChannelID, c.State, "some string", c.CreatedTime.Unix())
_, _ = dbConnection.Exec(s)
return c
}

View File

@ -0,0 +1,143 @@
package coupon
import (
"loreal.com/dit/cmd/coupon-service/base"
)
//ErrRuleJudgeNotFound - 找不到一个规则的校验。
var ErrRuleJudgeNotFound = base.ErrorWithCode{
Code: 1000,
Message: "rule judge not found",
}
//ErrRuleNotFound - 找不到一个规则的校验。
var ErrRuleNotFound = base.ErrorWithCode{
Code: 1001,
Message: "rule not found",
}
// ErrCouponRulesApplyTimesExceeded - 已经达到最大领用次数。
var ErrCouponRulesApplyTimesExceeded = base.ErrorWithCode{
Code: 1002,
Message: "coupon apply times exceeded",
}
// ErrCouponRulesApplyTimeExpired - 卡券已经过了申领期限。
var ErrCouponRulesApplyTimeExpired = base.ErrorWithCode{
Code: 1003,
Message: "the coupon applied has been expired",
}
// ErrCouponRulesBadFormat - 卡券附加的验证规则有错误。
var ErrCouponRulesBadFormat = base.ErrorWithCode{
Code: 1004,
Message: "the coupon has a bad formated rules",
}
// ErrCouponRulesRedemptionNotStart - 卡券还没开始核销。
var ErrCouponRulesRedemptionNotStart = base.ErrorWithCode{
Code: 1005,
Message: "the coupon has not start the redemption",
}
// ErrCouponRulesRedeemTimesExceeded - 已经达到最大领用次数。
var ErrCouponRulesRedeemTimesExceeded = base.ErrorWithCode{
Code: 1006,
Message: "coupon redeem times exceeded",
}
// ErrCouponRulesNoRedeemTimes - 已经达到最大领用次数。
var ErrCouponRulesNoRedeemTimes = base.ErrorWithCode{
Code: 1007,
Message: "coupon has no redeem times rule",
}
// ErrCouponRulesRedemptionExpired - 卡券核销已经过期。
var ErrCouponRulesRedemptionExpired = base.ErrorWithCode{
Code: 1008,
Message: "the coupon is expired",
}
// ErrCouponRulesUnsuportTimeUnit - 不支持的时间单位。
var ErrCouponRulesUnsuportTimeUnit = base.ErrorWithCode{
Code: 1009,
Message: "the coupon redeem time unit unsupport",
}
//ErrCouponTemplateNotFound - 签发新的Coupon时找不到Coupon的模板的类型
var ErrCouponTemplateNotFound = base.ErrorWithCode{
Code: 1100,
Message: "coupon template not found",
}
//ErrCouponIDInvalid - 签发新的Coupon时用户id或者卡券类型不合法
var ErrCouponIDInvalid = base.ErrorWithCode{
Code: 1200,
Message: "coupon id is invalid",
}
//ErrCouponNotFound - 没找到Coupon时
var ErrCouponNotFound = base.ErrorWithCode{
Code: 1201,
Message: "coupon not found",
}
//ErrCouponIsNotActive - 没找到Coupon时
var ErrCouponIsNotActive = base.ErrorWithCode{
Code: 1202,
Message: "coupon is not active",
}
//ErrCouponWasRedeemed - 没找到Coupon时
var ErrCouponWasRedeemed = base.ErrorWithCode{
Code: 1203,
Message: "coupon was redeemed",
}
//ErrCouponTooMuchToRedeem - 没找到Coupon时
var ErrCouponTooMuchToRedeem = base.ErrorWithCode{
Code: 1204,
Message: "too much coupons to redeem",
}
//ErrCouponWrongConsumer - 没找到Coupon时
var ErrCouponWrongConsumer = base.ErrorWithCode{
Code: 1205,
Message: "the coupon's owner is not the provided consumer",
}
//ErrConsumerIDAndCouponTypeIDInvalid - 签发新的Coupon时用户id或者卡券类型不合法
var ErrConsumerIDAndCouponTypeIDInvalid = base.ErrorWithCode{
Code: 1300,
Message: "consumer id or coupon type is invalid",
}
//ErrConsumerIDInvalid - 签发新的Coupon时用户id或者卡券类型不合法
var ErrConsumerIDInvalid = base.ErrorWithCode{
Code: 1301,
Message: "consumer id is invalid",
}
//ErrConsumerIDsAndRefIDsMismatch - 消费者的RefID和ID数量不匹配
var ErrConsumerIDsAndRefIDsMismatch = base.ErrorWithCode{
Code: 1302,
Message: "consumer ids and the ref ids mismatch",
}
//ErrRequesterForbidden - 没找到Coupon时
var ErrRequesterForbidden = base.ErrorWithCode{
Code: 1400,
Message: "requester was forbidden to do this action",
}
//ErrRequesterHasNoBrand - 没找到Coupon时
var ErrRequesterHasNoBrand = base.ErrorWithCode{
Code: 1401,
Message: "requester has no brand information",
}
//ErrRedeemWithDiffBrand - 没找到Coupon时
var ErrRedeemWithDiffBrand = base.ErrorWithCode{
Code: 1402,
Message: "redeem coupon with different brand",
}

View File

@ -0,0 +1,41 @@
package coupon
import (
// "database/sql"
"encoding/json"
// "fmt"
"log"
)
// GetPropertiesString 获取属性map[string]string的字符串格式。
func (c *Coupon) GetPropertiesString() (*string, error) {
jsonBytes, err := json.Marshal(c.Properties)
if nil != err {
log.Println(err)
return nil, err
}
var str = string(jsonBytes)
return &str, nil
}
// SetPropertiesFromString 根据string来设置Coupon属性map[string]string
func (c *Coupon) SetPropertiesFromString(properties string) error{
err := json.Unmarshal([]byte(properties), &c.Properties)
if nil != err {
log.Println(err)
return err
}
return nil
}
// GetRules 获取卡券的规则
func (c *Coupon) GetRules() map[string]interface{} {
var rules map[string]interface{}
var ok bool
if rules, ok = c.Properties[KeyBindingRuleProperties].(map[string]interface{}); ok {
return rules
}
// log.Println("============if rules, ok============" )
// log.Println(ok )
return nil
}

View File

@ -0,0 +1,828 @@
package coupon
import (
"database/sql"
"errors"
"strconv"
// "fmt"
"log"
// "net/http"
"strings"
"sync"
"time"
"loreal.com/dit/cmd/coupon-service/base"
"loreal.com/dit/utils"
"github.com/google/uuid"
)
var bufferSize int = 5
var templates []*Template
var publishedCouponTypes []*PublishedCouponType
var encryptKey []byte
// 做一下访问控制
var couponMessageChannels map[chan Message]bool
var serviceMutex *sync.RWMutex
// Init 初始化一些数据
// TODO: 目前是hard code的数据后期要重构
func Init(temps []*Template,
couponTypes []*PublishedCouponType,
rules []*Rule,
databaseConnection *sql.DB,
key string) {
templates = temps
publishedCouponTypes = couponTypes
couponMessageChannels = make(map[chan Message]bool)
encryptKey = []byte(key)
serviceMutex = new(sync.RWMutex)
staticsInit(databaseConnection)
ruleInit(rules)
dbInit()
}
// ActivateTestedCoupontypes 激活测试用的卡券
// TODO: 实现卡券类型的相关API后将会移除
func ActivateTestedCoupontypes() {
var cts []*PublishedCouponType
utils.LoadOrCreateJSON("coupon/test/coupon_types.json", &cts)
for _, t := range cts {
t.InitRules()
}
publishedCouponTypes = cts
log.Printf("[GetCoupons] 新的publishedCouponTypes: %#v\n", publishedCouponTypes)
}
func _checkCouponType(couponTypeID string) *PublishedCouponType {
var pct *PublishedCouponType
for _, value := range publishedCouponTypes {
if value.ID == couponTypeID {
pct = value
return pct
}
}
return nil
}
// GetCouponTypes 获取卡券类型列表
// TODO: 加上各种过滤条件
func GetCouponTypes(requester *base.Requester) ([]*PublishedCouponType, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetCoupons] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_ISSUER) && !requester.HasRole(base.ROLE_COUPON_REDEEMER) {
return nil, &ErrRequesterForbidden
}
// if base.IsBlankString(consumerID) {
// return nil, &ErrConsumerIDInvalid
// }
return publishedCouponTypes, nil
}
// IssueCoupons 批量签发卡券
// TODO: 测试下单次调用多少用户量合适
func IssueCoupons(requester *base.Requester, consumerIDs string, consumerRefIDs string, channelID string, couponTypeID string) (*[]*Coupon, map[string][]error, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[IssueCoupons] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_ISSUER) {
return nil, nil, &ErrRequesterForbidden
}
// 1.分拆 consumers
consumerIDArray := strings.Split(consumerIDs, ",")
consumerIDArray = removeDuplicatedConsumers(consumerIDArray)
var consumerRefIDArray []string = nil
if !base.IsBlankString(consumerRefIDs) {
consumerRefIDArray = strings.Split(consumerRefIDs, ",")
consumerRefIDArray = removeDuplicatedConsumers(consumerRefIDArray)
}
if consumerRefIDArray != nil && len(consumerIDArray) != len(consumerRefIDArray) {
return nil, nil, &ErrConsumerIDsAndRefIDsMismatch
}
// 2. 查询coupontype
// TODO: 未来改成数据库模式,此处要重构
pct := _checkCouponType(couponTypeID)
if nil == pct {
return nil, nil, &ErrCouponTemplateNotFound
}
// 3. 校验申请条件
// TODO: 判断是否有重复的rule
// 用来记录所有人的规则错误
// TODO: 此处尝试用 gorouting
// TODO: 记得测试有很多错误的情况下,前端得到什么。
var allConsumersRuleCheckErrors map[string][]error = make(map[string][]error, 0)
for _, consumerID := range consumerIDArray {
rerrs, rerr := validateTemplateRules(consumerID, couponTypeID, pct)
if rerr != nil {
switch rerr {
case &ErrRuleNotFound:
{
log.Println(ErrRuleNotFound)
return nil, nil, &ErrRuleNotFound
}
case &ErrRuleJudgeNotFound:
{
log.Println(ErrRuleJudgeNotFound)
return nil, nil, &ErrRuleJudgeNotFound
}
default:
{
log.Println("未知校验规则错误")
return nil, nil, errors.New("内部错误")
}
}
}
if len(rerrs) > 0 {
allConsumersRuleCheckErrors[consumerID] = rerrs
}
}
if len(allConsumersRuleCheckErrors) > 0 {
return nil, allConsumersRuleCheckErrors, nil
}
// 4. issue
lt := time.Now().Local()
composedRules, err := marshalCouponRules(requester, couponTypeID, pct.Rules)
if nil != err {
return nil, nil, err
}
var newCs []*Coupon = make([]*Coupon, 0, len(consumerIDArray))
for idx, consumerID := range consumerIDArray {
refID := ""
if consumerRefIDArray != nil {
refID = consumerRefIDArray[idx]
}
var newC = Coupon{
ID: uuid.New().String(),
CouponTypeID: couponTypeID,
ConsumerID: consumerID,
ConsumerRefID: refID,
ChannelID: channelID,
State: SActive,
Properties: make(map[string]interface{}),
CreatedTime: &lt,
Transactions: make([]*Transaction, 0),
}
newC.Properties[KeyBindingRuleProperties] = composedRules
newCs = append(newCs, &newC)
}
tx, err := dbConnection.Begin()
if err != nil {
log.Println(err)
return nil, nil, errors.New("内部错误")
}
err = createCoupons(newCs)
if nil != err {
tx.Rollback()
return nil, nil, err
}
// 5. 记录log
var ts []*Transaction = make([]*Transaction, 0, len(newCs))
for _, c := range newCs {
var t = Transaction{
ID: uuid.New().String(),
CouponID: c.ID,
ActorID: requester.UserID,
TransType: TTIssueCoupon,
ExtraInfo: "",
CreatedTime: time.Now().Local(),
}
ts = append(ts, &t)
}
err = createCouponTransactions(ts)
if nil != err {
tx.Rollback()
return nil, nil, err
}
tx.Commit()
return &newCs, nil, nil
}
// IssueCoupon 签发一个卡券
// func IssueCoupon(requester *base.Requester, consumerID string, couponTypeID string) (*Coupon, []error, error) {
// if !requester.HasRole(base.ROLE_COUPON_ISSUER) {
// return nil, nil, &ErrRequesterForbidden
// }
// //1. 查询coupontype
// // TODO: 未来改成数据库模式,此处要重构
// pct := _checkCouponType(couponTypeID)
// if nil == pct {
// return nil, nil, &ErrCouponTemplateNotFound
// }
// // 2. 校验申请条件
// // TODO: 判断是否有重复的rule
// rerrs, rerr := validateTemplateRules(consumerID, couponTypeID, pct)
// if rerr != nil {
// switch rerr {
// case &ErrRuleNotFound:
// {
// log.Println(ErrRuleNotFound)
// return nil, nil, &ErrRuleNotFound
// }
// case &ErrRuleJudgeNotFound:
// {
// log.Println(ErrRuleJudgeNotFound)
// return nil, nil, &ErrRuleJudgeNotFound
// }
// default:
// {
// log.Println("未知校验规则错误")
// return nil, nil, errors.New("内部错误")
// }
// }
// }
// if len(rerrs) > 0 {
// return nil, rerrs, nil
// }
// // 3. issue
// lt := time.Now().Local()
// var newC = Coupon{
// ID: uuid.New().String(),
// CouponTypeID: couponTypeID,
// ConsumerID: consumerID,
// State: SActive,
// Properties: make(map[string]string),
// CreatedTime: &lt,
// }
// composedRules, err := marshalCouponRules(requester, couponTypeID, pct.Rules)
// if nil != err {
// return nil, nil, err
// }
// newC.Properties[KeyBindingRuleProperties] = composedRules
// tx, err := dbConnection.Begin()
// if err != nil {
// log.Println(err)
// return nil, nil, errors.New("内部错误")
// }
// err = createCoupon(&newC)
// if nil != err {
// tx.Rollback()
// return nil, nil, err
// }
// // 4. 记录log
// var t = Transaction{
// ID: uuid.New().String(),
// CouponID: newC.ID,
// ActorID: requester.UserID,
// TransType: TTIssueCoupon,
// CreatedTime: time.Now().Local(),
// }
// err = createCouponTransaction(&t)
// if nil != err {
// tx.Rollback()
// return nil, nil, err
// }
// tx.Commit()
// return &newC, nil, nil
// }
// ValidateCouponExpired - Valiete whether request coupon is expired.
func ValidateCouponExpired(requester *base.Requester, c *Coupon) (bool, error) {
return validateCouponExpired(requester, c.ConsumerID, c)
}
// GetCoupon 获取一个卡券的信息
func GetCoupon(requester *base.Requester, id string) (*Coupon, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetCoupon] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_ISSUER) && !requester.HasRole(base.ROLE_COUPON_REDEEMER) {
return nil, &ErrRequesterForbidden
}
if base.IsBlankString(id) {
return nil, &ErrCouponIDInvalid
}
c, err := getCoupon(id)
if nil != err {
return nil, err
}
ts := make([]*Transaction, 0)
c.Transactions = ts
return c, nil
}
// GetCouponWithTransactions - Get coupon by specified couponID and return associated transactions with specified transType in the same time.
func GetCouponWithTransactions(requester *base.Requester, id string, transType string) (*Coupon, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetCoupon] 未知错误: %#v\n", v)
}
}()
c, err := GetCoupon(requester, id)
if nil != err {
return nil, err
}
transTypeID, err := strconv.Atoi(transType)
if err != nil {
return nil, err
}
ts := make([]*Transaction, 0)
ts, err = getCouponTransactionsWithType(id, TransType(transTypeID))
if err != nil {
return nil, err
}
c.Transactions = ts
return c, nil
}
// GetCoupons 搜索用户所有的卡券
// TODO: 增加各种过滤条件
func GetCoupons(requester *base.Requester, consumerID string, couponTypeID string) ([]*Coupon, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetCoupons] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_ISSUER) && !requester.HasRole(base.ROLE_COUPON_REDEEMER) {
return nil, &ErrRequesterForbidden
}
if base.IsBlankString(consumerID) {
return nil, &ErrConsumerIDInvalid
}
cs, err := getCoupons(consumerID, couponTypeID)
if nil != err {
return nil, err
}
return cs, nil
}
// GetCouponsWithTransactions - Get conpons and specified type of transactions
func GetCouponsWithTransactions(requester *base.Requester, consumerID string, couponTypeID string, transTypeID string) ([]*Coupon, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetCoupons] 未知错误: %#v\n", v)
}
}()
cs, err := GetCoupons(requester, consumerID, couponTypeID)
if err != nil {
return nil, err
}
for _, c := range cs {
tt, err := strconv.Atoi(transTypeID)
if err != nil {
return nil, err
}
ts, err := getCouponTransactionsWithType(c.ID, TransType(tt))
if err != nil {
return nil, err
}
if len(ts) == 0 {
c.Transactions = make([]*Transaction, 0)
}
c.Transactions = ts
}
return cs, nil
}
// RedeemCoupon 兑换卡券
func RedeemCoupon(requester *base.Requester, consumerID string, couponID string, extraInfo string) ([]error, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[RedeemCoupon] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_REDEEMER) {
return nil, &ErrRequesterForbidden
}
//1. 查询coupon
c, err := GetCoupon(requester, couponID)
if err != nil {
log.Println(err)
return nil, err
}
err = validateCouponBasicForRedeem(c, consumerID)
if nil != err {
return nil, err
}
// 2. validation rules
rerrs, rerr := validateCouponRules(requester, consumerID, c)
if rerr != nil {
return nil, &ErrRuleNotFound
}
if len(rerrs) > 0 {
return rerrs, nil
}
// 3. redeem
var newT = Transaction{
ID: uuid.New().String(),
CouponID: couponID,
ActorID: requester.UserID,
TransType: TTRedeemCoupon,
ExtraInfo: extraInfo,
CreatedTime: time.Now().Local(),
}
tx, err := dbConnection.Begin()
if err != nil {
log.Println(err)
return nil, errors.New("内部错误")
}
err = createCouponTransaction(&newT)
if nil != err {
tx.Rollback()
return nil, err
}
// 4. update coupon state
var count uint
count, err = restRedeemTimes(c)
if nil != err {
if &ErrCouponRulesNoRedeemTimes == err {
count = 0
} else {
tx.Rollback()
return nil, err
}
}
if 0 == count {
// 已经兑换完
c.State = SRedeemed
err = updateCouponState(c)
if nil != err {
tx.Rollback()
return nil, err
}
}
tx.Commit()
// 5. 发通知
// TODO: 缓存数据避免失败后丢失数据当然也可以采用从数据库捞数据的方式使用cursor
allCoupons := make([]*Coupon, 0)
allCoupons = append(allCoupons, c)
_sendRedeemMessage(allCoupons, extraInfo)
return nil, nil
}
func _addErrForConsumer(mps map[string][]error, consumerID string, err error) {
if mps[consumerID] == nil {
mps[consumerID] = make([]error, 0, 1)
}
mps[consumerID] = append(mps[consumerID], err)
}
// RedeemCouponByType 根据卡券类型兑换卡券,
// 要求消费者针对该类型卡券类型只有一张卡券
func RedeemCouponByType(requester *base.Requester, consumerIDs string, couponTypeID string, extraInfo string) (map[string][]error, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[RedeemCouponByType] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_REDEEMER) {
return nil, &ErrRequesterForbidden
}
var allConsumersRuleCheckErrors map[string][]error = make(map[string][]error, 0)
//1. 查询coupon
var consumerIDArray []string = make([]string, 0)
if !base.IsBlankString(consumerIDs) {
consumerIDArray = strings.Split(consumerIDs, ",")
}
allCoupons := make([]*Coupon, 0, len(consumerIDArray))
for _, consumerID := range consumerIDArray {
cs, err := GetCoupons(requester, consumerID, couponTypeID)
if err != nil {
log.Println(err)
_addErrForConsumer(allConsumersRuleCheckErrors, consumerID, err)
continue
}
if nil == cs || len(cs) == 0 {
_addErrForConsumer(allConsumersRuleCheckErrors, consumerID, &ErrCouponNotFound)
continue
}
// 目前限制只有一份卡券时才可以兑换
if len(cs) > 1 {
_addErrForConsumer(allConsumersRuleCheckErrors, consumerID, &ErrCouponTooMuchToRedeem)
continue
}
c := cs[0]
allCoupons = append(allCoupons, c)
err = validateCouponBasicForRedeem(c, consumerID)
if nil != err {
_addErrForConsumer(allConsumersRuleCheckErrors, consumerID, err)
continue
}
// 2. validation rules
rerrs, rerr := validateCouponRules(requester, consumerID, c)
if rerr != nil {
_addErrForConsumer(allConsumersRuleCheckErrors, consumerID, &ErrRuleNotFound)
continue
}
if len(rerrs) > 0 {
allConsumersRuleCheckErrors[consumerID] = append(allConsumersRuleCheckErrors[consumerID], rerrs...)
continue
}
}
if len(allConsumersRuleCheckErrors) > 0 {
return allConsumersRuleCheckErrors, nil
}
// 3. redeem
ts := make([]*Transaction, 0, len(allCoupons))
for _, c := range allCoupons {
var newT = Transaction{
ID: uuid.New().String(),
CouponID: c.ID,
ActorID: requester.UserID,
TransType: TTRedeemCoupon,
ExtraInfo: extraInfo,
CreatedTime: time.Now().Local(),
}
ts = append(ts, &newT)
}
tx, err := dbConnection.Begin()
if err != nil {
log.Println(err)
return nil, errors.New("内部错误")
}
err = createCouponTransactions(ts)
if nil != err {
tx.Rollback()
return nil, err
}
// 4. update coupon state
for _, c := range allCoupons {
var count uint
count, err = restRedeemTimes(c)
if nil != err {
if &ErrCouponRulesNoRedeemTimes == err {
count = 0
} else {
tx.Rollback()
return nil, err
}
}
if 0 == count {
// 已经兑换完
c.State = SRedeemed
err = updateCouponState(c)
if nil != err {
tx.Rollback()
return nil, err
}
}
}
tx.Commit()
// 5. 发通知
// TODO: 缓存数据避免失败后丢失数据当然也可以采用从数据库捞数据的方式使用cursor
_sendRedeemMessage(allCoupons, extraInfo)
return nil, nil
}
// RedeemCouponsInMaintenance - Redeem coupon in maintenance situation
func RedeemCouponsInMaintenance(requester *base.Requester, couponIDs string, extraInfo string) error {
if base.IsBlankString(couponIDs) {
return errors.New("empty couponIDs not allowed")
}
couponIDArray := strings.Split(couponIDs, ",")
var limit int = 50
var batches int = len(couponIDArray) / limit
if len(couponIDArray)%limit > 0 {
batches++
}
for i := 0; i < batches; i++ {
min := i * limit
max := (i + 1) * limit
if max > len(couponIDArray) {
max = len(couponIDArray)
}
cslice := couponIDArray[min:max]
coupons := make([]*Coupon, 0)
for _, cid := range cslice {
c, err := GetCoupon(requester, cid)
if err != nil {
log.Println(err)
continue
}
coupons = append(coupons, c)
}
redeemCoupons(requester, coupons, extraInfo)
}
return nil
}
func redeemCoupons(requester *base.Requester, allCoupons []*Coupon, extraInfo string) error {
ts := make([]*Transaction, 0, len(allCoupons))
for _, c := range allCoupons {
var newT = Transaction{
ID: uuid.New().String(),
CouponID: c.ID,
ActorID: requester.UserID,
TransType: TTRedeemCoupon,
ExtraInfo: extraInfo,
CreatedTime: time.Now().Local(),
}
ts = append(ts, &newT)
}
tx, err := dbConnection.Begin()
if err != nil {
log.Println(err)
return errors.New("内部错误")
}
err = createCouponTransactions(ts)
if nil != err {
tx.Rollback()
return err
}
for _, c := range allCoupons {
var count uint
count, err = restRedeemTimes(c)
if nil != err {
if &ErrCouponRulesNoRedeemTimes == err {
count = 0
} else {
tx.Rollback()
return err
}
}
if 0 == count {
// 已经兑换完
c.State = SRedeemed
err = updateCouponState(c)
if nil != err {
tx.Rollback()
return err
}
}
}
tx.Commit()
return nil
}
func validateCouponBasicForRedeem(c *Coupon, consumerID string) error {
if nil == c {
return &ErrCouponNotFound
}
if SRedeemed == c.State {
return &ErrCouponWasRedeemed
}
if SActive != c.State {
return &ErrCouponIsNotActive
}
if c.ConsumerID != consumerID {
return &ErrCouponWrongConsumer
}
return nil
}
//TODO: 关于消息还没有单元测试 []*Coupon
func _sendRedeemMessage(cs []*Coupon, extraInfo string) {
if nil == cs || len(cs) < 0 {
return
}
m := Message{
Type: MTRedeemed,
Payload: RedeemedCoupons{
ExtraInfo: extraInfo,
Coupons: cs,
},
}
serviceMutex.RLock()
defer serviceMutex.RUnlock()
for chn := range couponMessageChannels {
chn <- m
}
}
// DeleteCoupon 删除一个卡券。
// 注意,这里不是用户自己删除, 自己删除的需要考虑后续的业务场景,比如是否可以重新领取
func DeleteCoupon(id string) (*Coupon, error) {
return nil, nil
}
// GetLatestCouponMessage 获取最新的卡券信息
// TODO: 可能有多个服务器
// TODO: 使用cursor
func GetLatestCouponMessage(requester *base.Requester) (interface{}, error) {
defer func() {
if v := recover(); nil != v {
log.Printf("[GetLatestCouponMessage] 未知错误: %#v\n", v)
}
}()
if !requester.HasRole(base.ROLE_COUPON_LISTENER) {
return nil, &ErrRequesterForbidden
}
chn := make(chan Message)
serviceMutex.Lock()
couponMessageChannels[chn] = true
serviceMutex.Unlock()
defer func() {
close(chn)
serviceMutex.Lock()
delete(couponMessageChannels, chn)
serviceMutex.Unlock()
}()
msg := <-chn
return msg, nil
}
// removeDuplicatedConsumers 批量生成卡券时移除多余的consumerID和consumerRefID
func removeDuplicatedConsumers(consumers []string) []string {
result := make([]string, 0, len(consumers))
temp := map[string]struct{}{}
for _, item := range consumers {
if _, ok := temp[item]; !ok {
temp[item] = struct{}{}
result = append(result, item)
}
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,304 @@
package coupon
import (
// "encoding/json"
"log"
"time"
"loreal.com/dit/cmd/coupon-service/base"
"github.com/jinzhu/now"
"github.com/mitchellh/mapstructure"
)
// var daysMap = map[string]int {
// "YEAR" : 366, //理想的是根据是否闰年来计算
// }
// TemplateJudge 发卡券时用来验证是否符合rules
type TemplateJudge interface {
// JudgeTemplate 验证模板
JudgeTemplate(consumerID string, couponTypeID string, ruleBody map[string]interface{}, pct *PublishedCouponType) error
}
// Judge 兑换卡券时用来验证是否符合rules
type Judge interface {
// JudgeCoupon 验证模板
JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error
}
// RedeemPeriodJudge 验证有效期
type RedeemPeriodJudge struct {
}
// RedeemInCurrentNatureTimeUnitJudge 验证自然月,季度,年度有效期
type RedeemInCurrentNatureTimeUnitJudge struct {
}
//ApplyTimesJudge 验证领用次数
type ApplyTimesJudge struct {
}
//RedeemTimesJudge 验证兑换次数
type RedeemTimesJudge struct {
}
//RedeemBySameBrandJudge 验证是否同品牌兑换
type RedeemBySameBrandJudge struct {
}
// TODO: 重构rule结构实现这些接口统一处理。
// JudgeCoupon 验证有效期
// TODO: 未来加上DAY, WEEK, SEARON, YEAR 等
func (*RedeemInCurrentNatureTimeUnitJudge) JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error {
var ntu natureTimeUnit
// var err error
var start time.Time
var end time.Time
if err := mapstructure.Decode(ruleBody, &ntu); err != nil {
return &ErrCouponRulesBadFormat
}
// TODO: 支持季度,年啥的。
if base.IsBlankString(ntu.Unit) || ntu.Unit != "MONTH" {
return &ErrCouponRulesUnsuportTimeUnit
}
switch ntu.Unit {
case "MONTH":
{
ctMonth := now.With(*c.CreatedTime)
start = ctMonth.BeginningOfMonth()
end = ctMonth.EndOfMonth().AddDate(0, 0, ntu.EndInAdvance*-1)
}
}
n := time.Now()
if n.Before(start) {
return &ErrCouponRulesRedemptionNotStart
}
if n.After(end) {
return &ErrCouponRulesRedemptionExpired
}
return nil
}
// JudgeCoupon 验证有效期
func (*RedeemPeriodJudge) JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error {
var ts timeSpan
var err error
var start time.Time
var end time.Time
// err := json.Unmarshal([]byte(jsonString), &ts)
// if err != nil {
// log.Println(err)
// return &ErrCouponRulesBadFormat
// }
// var startTime, endTime string
// var ok bool
// if startTime, ok = ruleBody["startTime"].(string); !ok {
// return &ErrCouponRulesBadFormat
// }
if err := mapstructure.Decode(ruleBody, &ts); err != nil {
return &ErrCouponRulesBadFormat
}
if !base.IsBlankString(ts.StartTime) {
start, err = time.Parse(timeLayout, ts.StartTime)
if nil != err {
log.Println(err)
return &ErrCouponRulesBadFormat
}
}
// if endTime, ok = ruleBody["endTime"].(string); !ok {
// return &ErrCouponRulesBadFormat
// }
if !base.IsBlankString(ts.EndTime) {
end, err = time.Parse(timeLayout, ts.EndTime)
if nil != err {
log.Println(err)
return &ErrCouponRulesBadFormat
}
}
if time.Now().Before(start) {
return &ErrCouponRulesRedemptionNotStart
}
if time.Now().After(end) {
return &ErrCouponRulesRedemptionExpired
}
return nil
}
// JudgeCoupon 验证兑换次数
func (*RedeemTimesJudge) JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error {
var rt redeemTimes
// err := json.Unmarshal([]byte(jsonString), &rt)
// if err != nil {
// log.Println(err)
// return err
// }
tas, err := getCouponTransactionsWithType(c.ID, TTRedeemCoupon)
if err != nil {
return err
}
if nil == tas {
return nil
}
// var i int
// var ok bool
// if i, ok = ruleBody["times"].(int); !ok {
// return &ErrCouponRulesBadFormat
// }
if err := mapstructure.Decode(ruleBody, &rt); err != nil {
return &ErrCouponRulesBadFormat
}
if len(tas) >= int(rt.Times) {
return &ErrCouponRulesRedeemTimesExceeded
}
return nil
}
// JudgeCoupon 验证只能同品牌兑换
func (*RedeemBySameBrandJudge) JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error {
if base.IsEmptyString(requester.Brand) {
return &ErrRequesterHasNoBrand
}
var brand sameBrand
// err := json.Unmarshal([]byte(jsonString), &brand)
// if err != nil {
// log.Println(err)
// return err
// }
// var brand string
// var ok bool
if err := mapstructure.Decode(ruleBody, &brand); err != nil {
return &ErrCouponRulesBadFormat
}
// if brand, ok = ruleBody["brand"].(string); !ok {
// return &ErrCouponRulesBadFormat
// }
if requester.Brand != brand.Brand {
return &ErrRedeemWithDiffBrand
}
return nil
}
// JudgeTemplate 验证领用次数
func (*ApplyTimesJudge) JudgeTemplate(consumerID string, couponTypeID string, ruleBody map[string]interface{}, pct *PublishedCouponType) error {
coupons, err := getCoupons(consumerID, couponTypeID)
if err != nil {
return err
}
var at applyTimes
// err2 := json.Unmarshal([]byte(jsonString), &at)
// if err2 != nil {
// log.Println(err2)
// return err2
// }
// var i int
// var ok bool
if err := mapstructure.Decode(ruleBody, &at); err != nil {
return &ErrCouponRulesBadFormat
}
// if i, ok = ruleBody["inDays"].(int); !ok {
// return &ErrCouponRulesBadFormat
// }
if at.InDays > 0 {
var expiredTime = pct.CreatedTime.AddDate(0, 0, int(at.InDays))
if !time.Now().Before(expiredTime) {
return &ErrCouponRulesApplyTimeExpired
}
}
// if i, ok = ruleBody["times"].(int); !ok {
// return &ErrCouponRulesBadFormat
// }
if at.Times <= uint(len(coupons)) {
return &ErrCouponRulesApplyTimesExceeded
}
return nil
}
// JudgeNTUExpired - To validate experiation status of coupon for NTU type.
func JudgeNTUExpired(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) (bool, error) {
var ntu natureTimeUnit
var end time.Time
if err := mapstructure.Decode(ruleBody, &ntu); err != nil {
return false, &ErrCouponRulesBadFormat
}
if base.IsBlankString(ntu.Unit) || ntu.Unit != "MONTH" {
return false, &ErrCouponRulesUnsuportTimeUnit
}
switch ntu.Unit {
case "MONTH":
{
ctMonth := now.With(*c.CreatedTime)
end = ctMonth.EndOfMonth().AddDate(0, 0, ntu.EndInAdvance*-1)
}
}
n := time.Now()
if n.After(end) {
return true, nil
}
return false, nil
}
// JudgePeriodExpired - To validate experiation status of coupon for normal period type.
func JudgePeriodExpired(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) (bool, error) {
var ts timeSpan
var err error
var end time.Time
if err := mapstructure.Decode(ruleBody, &ts); err != nil {
return false, &ErrCouponRulesBadFormat
}
if !base.IsBlankString(ts.EndTime) {
end, err = time.Parse(timeLayout, ts.EndTime)
if nil != err {
log.Println(err)
return false, &ErrCouponRulesBadFormat
}
}
if time.Now().After(end) {
return true, nil
}
return false, nil
}

View File

@ -0,0 +1,432 @@
package coupon
import (
// "encoding/json"
"fmt"
// "reflect"
"testing"
"time"
// "loreal.com/dit/cmd/coupon-service/base"
"bou.ke/monkey"
"github.com/jinzhu/now"
. "github.com/smartystreets/goconvey/convey"
)
func Test_ValidPeriodJudge_judgeCoupon(t *testing.T) {
var judge RedeemPeriodJudge
Convey("Given a regular time span and include today", t, func() {
ruleBody := map[string]interface{}{
"startTime": "2020-01-01 00:00:00 +08",
"endTime": "3020-02-14 00:00:00 +08",
}
// var ruleBody = `{"startTime": "2020-01-01 00:00:00 +08", "endTime": "3020-02-14 00:00:00 +08" }`
//base.RandString(4)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, nil)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
})
Convey("Given a past time span", t, func() {
ruleBody := map[string]interface{}{
"startTime": "1989-06-04 01:23:45 +08",
"endTime": "2008-02-08 20:08:00 +08",
}
// var ruleBody = `{"startTime": "1989-06-04 01:23:45 +08", "endTime": "2008-02-08 20:08:00 +08" }`
//base.RandString(4)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, nil)
Convey("Should ErrCouponRulesRedemptionExpired", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionExpired)
})
})
})
Convey("Given a future time span", t, func() {
ruleBody := map[string]interface{}{
"startTime": "3456-07-08 09:10:12 +08",
"endTime": "3456-07-08 09:20:12 +08",
}
// var ruleBody = `{"startTime": "3456-07-08 09:10:12 +08", "endTime": "3456-07-08 09:20:12 +08" }`
//base.RandString(4)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, nil)
Convey("Should ErrCouponRulesRedemptionNotStart", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionNotStart)
})
})
})
Convey("Given some time spans with bad format", t, func() {
Convey("bad start format", func() {
ruleBody := map[string]interface{}{
"startTime": 1234567890,
"endTime": "3456-07-08 09:20:12 +08",
}
// var ruleBody = `{"startTime": 1234567890, "endTime": "3456-07-08 09:20:12 +08" }`
err := judge.JudgeCoupon(nil, "", ruleBody, nil)
Convey("Should ErrCouponRulesBadFormat", func() {
So(err, ShouldEqual, &ErrCouponRulesBadFormat)
})
})
Convey("bad end format", func() {
ruleBody := map[string]interface{}{
"startTime": "3456-07-08 09:10:12 +08",
"endTime": 1234567890,
}
// var ruleBody = `{"startTime": "3456-07-08 09:10:12 +08", "endTime": "3456=07-08 09:20:12 +08" }`
err := judge.JudgeCoupon(nil, "", ruleBody, nil)
Convey("Should ErrCouponRulesBadFormat", func() {
So(err, ShouldEqual, &ErrCouponRulesBadFormat)
})
})
})
}
func Test_RedeemInCurrentNatureMonthSeasonYear_judgeCoupon(t *testing.T) {
var judge RedeemInCurrentNatureTimeUnitJudge
ruleBody := map[string]interface{}{
"unit": "MONTH",
"endInAdvance": 0,
}
Convey("Given a regular sample", t, func() {
// var ruleBody = `{"startTime": "2020-01-01 00:00:00 +08", "endTime": "3020-02-14 00:00:00 +08" }`
//base.RandString(4)
c := _aCoupon("abc", "xxx", "yyy", "def", 0, nil)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, c)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
})
Convey("Given a coupon applied last month", t, func() {
c := _aCoupon("abc", "xxx", "yyy", "def", 0, nil)
ct := c.CreatedTime.AddDate(0, 0, -31)
c.CreatedTime = &ct
fmt.Print(c.CreatedTime)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, c)
Convey("Should ErrCouponRulesRedemptionExpired", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionExpired)
})
})
})
Convey("Given a coupon applied before the month, maybe caused by daylight saving time", t, func() {
c := _aCoupon("abc", "xxx", "yyy", "def", 0, nil)
ct := c.CreatedTime.AddDate(0, 0, 31)
c.CreatedTime = &ct
// var ruleBody = `{"startTime": "3456-07-08 09:10:12 +08", "endTime": "3456-07-08 09:20:12 +08" }`
//base.RandString(4)
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, c)
Convey("Should ErrCouponRulesRedemptionNotStart", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionNotStart)
})
})
})
Convey("Given a time unit with unsupport unit", t, func() {
Convey("season unit...", func() {
rb := map[string]interface{}{
"unit": "SEASON",
"endInAdvance": 0,
}
err := judge.JudgeCoupon(nil, "", rb, nil)
Convey("Should ErrCouponRulesUnsuportTimeUnit", func() {
So(err, ShouldEqual, &ErrCouponRulesUnsuportTimeUnit)
})
})
Convey("year unit...", func() {
rb := map[string]interface{}{
"unit": "YEAR",
"endInAdvance": 0,
}
err := judge.JudgeCoupon(nil, "", rb, nil)
Convey("Should ErrCouponRulesUnsuportTimeUnit", func() {
So(err, ShouldEqual, &ErrCouponRulesUnsuportTimeUnit)
})
})
})
Convey("Given a sample with not default endInAdvance", t, func() {
rb := map[string]interface{}{
"unit": "MONTH",
"endInAdvance": 10,
}
c := _aCoupon("abc", "xxx", "yyy", "def", 0, nil)
nov := now.With(time.Date(2020, time.November, 2, 0, 0, 0, 0, time.UTC))
pg1 := monkey.Patch(now.With, func(time.Time) *now.Now {
return nov
})
Convey("assume in valid period", func() {
ct := time.Date(2020, time.November, 15, 0, 0, 0, 0, time.UTC)
pg2 := monkey.Patch(time.Now, func() time.Time {
return ct
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", rb, c)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
pg2.Unpatch()
})
Convey("assume out of period", func() {
ct := time.Date(2020, time.November, 30, 0, 0, 0, 0, time.UTC)
pg2 := monkey.Patch(time.Now, func() time.Time {
return ct
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", rb, c)
Convey("Should ErrCouponRulesRedemptionExpired", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionExpired)
})
})
pg2.Unpatch()
})
pg1.Unpatch()
})
Convey("Given a sample with not default endInAdvance", t, func() {
rb := map[string]interface{}{
"unit": "MONTH",
"endInAdvance": -10,
}
c := _aCoupon("abc", "xxx", "yyy", "def", 0, nil)
nov := now.With(time.Date(2020, time.November, 2, 0, 0, 0, 0, time.UTC))
pg1 := monkey.Patch(now.With, func(time.Time) *now.Now {
return nov
})
Convey("assume in valid period", func() {
ct := time.Date(2020, time.December, 5, 0, 0, 0, 0, time.UTC)
pg2 := monkey.Patch(time.Now, func() time.Time {
return ct
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", rb, c)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
pg2.Unpatch()
})
Convey("assume out of period", func() {
ct := time.Date(2020, time.December, 15, 0, 0, 0, 0, time.UTC)
pg2 := monkey.Patch(time.Now, func() time.Time {
return ct
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", rb, c)
Convey("Should ErrCouponRulesRedemptionExpired", func() {
So(err, ShouldEqual, &ErrCouponRulesRedemptionExpired)
})
})
pg2.Unpatch()
})
pg1.Unpatch()
})
}
func Test_RedeemTimesJudge_judgeCoupon(t *testing.T) {
var judge RedeemTimesJudge
var c Coupon
Convey("Given a valid redeem times", t, func() {
ruleBody := map[string]interface{}{
"times": 2,
}
// var ruleBody = `{"times": 2}`
Convey("Fisrt give some redeem logs which less than the coupon max redeem times", func() {
monkey.Patch(getCouponTransactionsWithType, func(_ string, _ TransType) ([]*Transaction, error) {
return make([]*Transaction, 1, 1), nil
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, &c)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
})
Convey("Second give no redeem logs", func() {
monkey.Patch(getCouponTransactionsWithType, func(_ string, _ TransType) ([]*Transaction, error) {
return nil, nil
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, &c)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
})
})
})
Convey("Third give some redeem logs which greater than the coupon max redeem times", func() {
monkey.Patch(getCouponTransactionsWithType, func(_ string, _ TransType) ([]*Transaction, error) {
return make([]*Transaction, 3, 3), nil
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, &c)
Convey("Should has ErrCouponRulesRedeemTimesExceeded", func() {
So(err, ShouldEqual, &ErrCouponRulesRedeemTimesExceeded)
})
})
})
Convey("If has some db err....", func() {
monkey.Patch(getCouponTransactionsWithType, func(_ string, _ TransType) ([]*Transaction, error) {
return nil, fmt.Errorf("hehehe")
})
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(nil, "", ruleBody, &c)
Convey("Should no errors", func() {
So(err, ShouldNotBeNil)
})
})
})
})
}
func Test_RedeemBySameBrandJudge_judgeCoupon(t *testing.T) {
var judge RedeemBySameBrandJudge
var brand = "Lancome"
Convey("Given a reqeust with no brand", t, func() {
var requester = _aRequester("", nil, "")
ruleBody := map[string]interface{}{}
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(requester, "", ruleBody, nil)
Convey("Should has ErrRequesterHasNoBrand", func() {
So(err, ShouldEqual, &ErrRequesterHasNoBrand)
})
})
})
Convey("Given a bad fromat rule body", t, func() {
var requester = _aRequester("", nil, brand)
ruleBody := map[string]interface{}{
"bra--nd": "Lancome",
}
// var ruleBody = `{"brand"="Lancome"}`
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(requester, "", ruleBody, nil)
Convey("Should has error", func() {
So(err, ShouldNotBeNil)
})
})
})
Convey("Given a request with wrong brand", t, func() {
var requester = _aRequester("", nil, brand)
ruleBody := map[string]interface{}{
"brand": "Lancome+bad+brand",
}
// var ruleBody = `{"brand":"Lancome+bad+brand"}`
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(requester, "", ruleBody, nil)
Convey("Should has ErrRedeemWithDiffBrand", func() {
So(err, ShouldEqual, &ErrRedeemWithDiffBrand)
})
})
})
Convey("Given a request with correct brand", t, func() {
var requester = _aRequester("", nil, brand)
ruleBody := map[string]interface{}{
"brand": "Lancome",
}
// var ruleBody = `{"brand":"Lancome"}`
Convey("Call JudgeCoupon", func() {
err := judge.JudgeCoupon(requester, "", ruleBody, nil)
Convey("Should has no error", func() {
So(err, ShouldBeNil)
})
})
})
}
func Test_ApplyTimesJudge_judgeCoupon(t *testing.T) {
var judge ApplyTimesJudge
Convey("The data base has something wrong... ", t, func() {
monkey.Patch(getCoupons, func(_ string, _ string) ([]*Coupon, error) {
return nil, fmt.Errorf("hehehe")
})
Convey("Call JudgeTemplate", func() {
ruleBody := map[string]interface{}{}
err := judge.JudgeTemplate("", "", ruleBody, nil)
Convey("Should has error", func() {
So(err, ShouldNotBeNil)
})
})
})
Convey("Given a coupon template which is expired", t, func() {
ruleBody := map[string]interface{}{
"inDays": 365,
"times": 2,
}
// var ruleBody = `{"inDays": 365, "times": 2 }`
var pct PublishedCouponType
pct.CreatedTime = time.Now().Local().AddDate(0, 0, -366)
monkey.Patch(getCoupons, func(_ string, _ string) ([]*Coupon, error) {
return nil, nil
})
Convey("Call JudgeTemplate", func() {
err := judge.JudgeTemplate("", "", ruleBody, &pct)
Convey("Should has ErrCouponRulesApplyTimeExpired", func() {
So(err, ShouldEqual, &ErrCouponRulesApplyTimeExpired)
})
})
})
Convey("Set the user had applyed coupons and reach the max times", t, func() {
ruleBody := map[string]interface{}{
"inDays": 365,
"times": 2,
}
// var ruleBody = `{"inDays": 365, "times": 2 }`
var pct PublishedCouponType
pct.CreatedTime = time.Now().Local()
monkey.Patch(getCoupons, func(_ string, _ string) ([]*Coupon, error) {
return make([]*Coupon, 2, 2), nil
})
Convey("Call JudgeTemplate", func() {
err := judge.JudgeTemplate("", "", ruleBody, &pct)
Convey("Should has ErrCouponRulesApplyTimesExceeded", func() {
So(err, ShouldEqual, &ErrCouponRulesApplyTimesExceeded)
})
})
})
Convey("Set the user has not reach the max appling times", t, func() {
ruleBody := map[string]interface{}{
"inDays": 365,
"times": 2,
}
// var ruleBody = `{"inDays": 365, "times": 2 }`
var pct PublishedCouponType
pct.CreatedTime = time.Now().Local()
monkey.Patch(getCoupons, func(_ string, _ string) ([]*Coupon, error) {
return make([]*Coupon, 1, 2), nil
})
Convey("Call JudgeTemplate", func() {
err := judge.JudgeTemplate("", "", ruleBody, &pct)
Convey("Should has ErrCouponRulesApplyTimesExceeded", func() {
So(err, ShouldBeNil)
})
})
})
}

View File

@ -0,0 +1,100 @@
package coupon
import (
// "encoding/json"
// "log"
"time"
// "loreal.com/dit/cmd/coupon-service/coupon"
"loreal.com/dit/cmd/coupon-service/base"
"github.com/mitchellh/mapstructure"
)
const timeLayout string = "2006-01-02 15:04:05 -07"
// BodyComposer 发卡时生成rule的body用来存在coupon中
type BodyComposer interface {
Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error)
}
// RedeemTimesBodyComposer 生成兑换次数的内容
type RedeemTimesBodyComposer struct {
}
// RedeemBySameBrandBodyComposer 生成兑换次数的内容
type RedeemBySameBrandBodyComposer struct {
}
// RedeemPeriodWithOffsetBodyComposer 生成有效期的内容
type RedeemPeriodWithOffsetBodyComposer struct {
}
// RedeemInCurrentNatureTimeUnitBodyComposer 生成有效期的内容
type RedeemInCurrentNatureTimeUnitBodyComposer struct {
}
// Compose 生成兑换次数的内容
// 规则体像 {"times": 1024}
func (*RedeemTimesBodyComposer) Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error) {
return ruleBody, nil
}
// Compose 生成由同品牌才能兑换的内容
// 规则体像 {"brand":"Lancome"}
func (*RedeemBySameBrandBodyComposer) Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error) {
if base.IsEmptyString(requester.Brand) {
return nil, &ErrRequesterHasNoBrand
}
var brand = make(map[string]interface{})
brand["brand"] = requester.Brand
// var brand = sameBrand{
// Brand: requester.Brand,
// }
return brand, nil
// return (&brand).(map[string]interface{}), nil
}
// Compose 生成卡券有效期的规则体
// 模板内的规则类似这样的格式: {"offSetFromAppliedDay": 14,"timeSpan": 365} 表示从领用日期延期14天后生效可以兑换截止日期是14+365天内。
// 如果offSetFromAppliedDay=0则当天生效。如果 timeSpan=0则无过期时间。
// 时间单位都是天。
// 生成卡券后的规则:{ "startTime": "2020-02-14T00:00:00+08:00", "endTime": null } 表示从2020-02-14日 0点开始无过期时间。
func (*RedeemPeriodWithOffsetBodyComposer) Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error) {
var offset offsetSpan
if err := mapstructure.Decode(ruleBody, &offset); err != nil {
return nil, &ErrCouponRulesBadFormat
}
var span = make(map[string]interface{})
// var i int
// var ok bool
// if i, ok = ruleBody["offSetFromAppliedDay"].(int); !ok {
// return nil, &ErrCouponRulesBadFormat
// }
var st = time.Now().Local().AddDate(0, 0, int(offset.OffSetFromAppliedDay))
// 时分秒清零
// st = Time.Date(st.Year())
span["startTime"] = st.Format(timeLayout)
// if i, ok = ruleBody["timeSpan"].(int); !ok {
// return nil, &ErrCouponRulesBadFormat
// }
ts := offset.TimeSpan
if 0 != ts {
var et = st.AddDate(0, 0, int(ts))
span["endTime"] = et.Format(timeLayout)
}
return span, nil
}
// Compose 生成卡券有效期的规则体,基于自然月,季度,年等。
// 模板内的规则类似这样的格式: {"unit": "MONTH", "endInAdvance": 5} 表示领用当月生效但在当月结束前5天过期。
// endInAdvance 的单位是 天, 默认为 0。
func (*RedeemInCurrentNatureTimeUnitBodyComposer) Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error) {
return ruleBody, nil
}

View File

@ -0,0 +1,149 @@
package coupon
import (
// "encoding/json"
// "fmt"
// "reflect"
"testing"
"time"
"loreal.com/dit/cmd/coupon-service/base"
. "github.com/chenhg5/collection"
"github.com/mitchellh/mapstructure"
. "github.com/smartystreets/goconvey/convey"
// "bou.ke/monkey"
)
func Test_RedeemTimesBodyComposer_compose(t *testing.T) {
Convey("Given a RedeemTimesBodyComposer instance and some input", t, func() {
var composer RedeemTimesBodyComposer
// var ruleInternalID = base.RandString(4)
ruleBody := map[string]interface{}{
"times": 123,
}
Convey("Call Compose", func() {
rrf, _ := composer.Compose(nil, "", ruleBody)
Convey("The composed value should contain correct value", func() {
So(Collect(rrf).Has("times"), ShouldBeTrue)
})
})
})
// var st = time.Now().Local().AddDate(0, 0, 0)
// fmt.Print(st.String())
// t.Errorf(st.Format("2006-01-02 15:04:05 -07"))
}
func Test_RedeemInCurrentNatureTimeUnitBodyComposer_compose(t *testing.T) {
Convey("Given a RedeemInCurrentNatureTimeUnitBodyComposer instance and a rule body with positive value", t, func() {
var composer RedeemInCurrentNatureTimeUnitBodyComposer
// var ruleInternalID = base.RandString(4)
ruleBody := map[string]interface{}{
"unit": "MONTH",
"endInAdvance": 10,
}
Convey("Call Compose", func() {
rrf, _ := composer.Compose(nil, "", ruleBody)
Convey("The composed value should contain correct value", func() {
So(Collect(rrf).Has("endInAdvance"), ShouldBeTrue)
So(Collect(rrf).Has("unit"), ShouldBeTrue)
var ntu natureTimeUnit
mapstructure.Decode(rrf, &ntu)
So(ntu.Unit, ShouldEqual, "MONTH")
So(ntu.EndInAdvance, ShouldEqual, 10)
})
})
})
Convey("Given a RedeemInCurrentNatureTimeUnitBodyComposer instance and a rule body with negative value", t, func() {
var composer RedeemInCurrentNatureTimeUnitBodyComposer
// var ruleInternalID = base.RandString(4)
ruleBody := map[string]interface{}{
"unit": "MONTH",
"endInAdvance": -10,
}
Convey("Call Compose", func() {
rrf, _ := composer.Compose(nil, "", ruleBody)
Convey("The composed value should contain correct value", func() {
So(Collect(rrf).Has("endInAdvance"), ShouldBeTrue)
So(Collect(rrf).Has("unit"), ShouldBeTrue)
var ntu natureTimeUnit
mapstructure.Decode(rrf, &ntu)
So(ntu.Unit, ShouldEqual, "MONTH")
So(ntu.EndInAdvance, ShouldEqual, -10)
})
})
})
}
func Test_RedeemBySameBrandBodyComposer_compose(t *testing.T) {
var composer RedeemBySameBrandBodyComposer
// var ruleInternalID = base.RandString(4)
ruleBody := map[string]interface{}{}
Convey("Given a RedeemBySameBrandBodyComposer instance and some input", t, func() {
var brand = base.RandString(4)
var requester = _aRequester("", nil, brand)
// monkey.PatchInstanceMethod(reflect.TypeOf(requester), "HasRole", func(_ *base.Requester, _ string) bool {
// return true
// })
Convey("Call Compose", func() {
rrf, _ := composer.Compose(requester, "", ruleBody)
Convey("The composed value should contain brand info", func() {
// So(ruleInternalID, ShouldEqual, rrf.RuleInternalID)
// var sb sameBrand
// _ = json.Unmarshal([]byte(rrf.RuleBody), &sb)
So(Collect(rrf).Has("brand"), ShouldBeTrue)
So(rrf["brand"], ShouldEqual, brand)
})
})
})
Convey("Given a requester with no brand", t, func() {
var requester = _aRequester("", nil, "")
Convey("Call Compose", func() {
_, err := composer.Compose(requester, "", ruleBody)
Convey("The call should failed with ErrRequesterHasNoBrand", func() {
So(err, ShouldEqual, &ErrRequesterHasNoBrand)
})
})
})
}
func Test_ValidPeriodWithOffsetBodyComposer_compose(t *testing.T) {
var composer RedeemPeriodWithOffsetBodyComposer
// var ruleInternalID = base.RandString(4)
Convey("Given a RedeemPeriodWithOffsetBodyComposer instance and some input", t, func() {
var offSetFromAppliedDay = r.Intn(1000)
var span = r.Intn(1000)
ruleBody := map[string]interface{}{
"offSetFromAppliedDay": offSetFromAppliedDay,
"timeSpan": span,
}
// var bodyString = fmt.Sprintf(`{"offSetFromAppliedDay": %d,"timeSpan": %d}`, offSetFromAppliedDay, span)
// monkey.PatchInstanceMethod(reflect.TypeOf(requester), "HasRole", func(_ *base.Requester, _ string) bool {
// return true
// })
Convey("Call Compose", func() {
rrf, _ := composer.Compose(nil, "", ruleBody)
Convey("The composed value should contain an offset value", func() {
// So(ruleInternalID, ShouldEqual, rrf.RuleInternalID)
var st = time.Now().Local().AddDate(0, 0, offSetFromAppliedDay)
var end = st.AddDate(0, 0, span)
// var ts timeSpan
// _ = json.Unmarshal([]byte(rrf.RuleBody), &ts)
So(Collect(rrf).Has("startTime"), ShouldBeTrue)
So(Collect(rrf).Has("endTime"), ShouldBeTrue)
So(rrf["startTime"], ShouldEqual, st.Format("2006-01-02 15:04:05 -07"))
So(rrf["endTime"], ShouldEqual, end.Format("2006-01-02 15:04:05 -07"))
})
})
})
}

View File

@ -0,0 +1,21 @@
package coupon
// MessageType 消息类型
type MessageType int32
const (
//MTIssue - Issue
MTIssue MessageType = iota
//MTRevoked - Revoked
MTRevoked
//MTRedeemed - Redeemed
MTRedeemed
//MTUnknown - Unknown
MTUnknown
)
// Message 消息结构
type Message struct {
Type MessageType `json:"type,omitempty"`
Payload interface{} `json:"payload,omitempty"`
}

View File

@ -0,0 +1,150 @@
package coupon
import (
"encoding/json"
"log"
"time"
"loreal.com/dit/utils"
// "loreal.com/dit/cmd/coupon-service/rule"
)
// KeyBindingRuleProperties 生成的Coupon包含类型为map的Properties字段用来保存多样化数据。KeyBindingRuleProperties对应的值是该券在兑换时需要满足的条件。
const KeyBindingRuleProperties string = "binding_rule_properties"
// Template 卡券的模板
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Creator string `json:"creator"`
Rules map[string]interface{} `json:"rules"`
CreatedTime time.Time `json:"created_time,omitempty" type:"DATETIME" default:"datetime('now','localtime')"`
UpdatedTime time.Time `json:"updated_time,omitempty" type:"DATETIME"`
DeletedTime time.Time `json:"deleted_time,omitempty" type:"DATETIME"`
}
// CTState 卡券类型的状态定义
type CTState int32
// 卡券的状态
const (
CTSActive State = iota
CTSRevoked
CTSUnknown
)
// PublishedCouponType 已经发布的卡券类型
type PublishedCouponType struct {
ID string `json:"id"`
Name string `json:"name"`
TemplateID string `json:"template_id"`
Description string `json:"description"`
InternalDescription string `json:"internal_description"`
State CTState `json:"state"`
Publisher string `json:"publisher"`
VisibleStartTime time.Time `json:"visible_start_time" type:"DATETIME"`
VisibleEndTime time.Time `json:"visible_end_time"`
StrRules map[string]string `json:"rules"`
Rules map[string]map[string]interface{}
CreatedTime time.Time `json:"created_time" type:"DATETIME" default:"datetime('now','localtime')"`
DeletedTime time.Time `json:"deleted_time" type:"DATETIME"`
}
// InitRules //TODO 未来会重构掉
func (t *PublishedCouponType) InitRules() {
t.Rules = map[string]map[string]interface{}{}
for k, v := range t.StrRules {
var obj map[string]interface{}
err := json.Unmarshal([]byte(v), &obj)
if nil == err {
t.Rules[k] = obj
} else {
log.Panic(err)
}
}
}
// State 卡券的状态类型定义
type State int32
// 卡券的状态
// TODO [HUBIN]: 增加 SExpired 状态
const (
SActive State = iota //如果非一次兑换类型那么在有效兑换次数内仍然是active
SRevoked
SDeleteCoupon
SRedeemed //无论多次还是一次,全部用完后置为该状态
SExpired
SUnknown
)
// Coupon 用来封装一个Coupon实体的结构
type Coupon struct {
ID string
CouponTypeID string
ConsumerID string
ConsumerRefID string
ChannelID string
State State
Properties map[string]interface{}
CreatedTime *time.Time
Transactions []*Transaction
}
// CreatedTimeToLocal 使用localtime
func (c *Coupon) CreatedTimeToLocal() {
l := c.CreatedTime.Local()
c.CreatedTime = &l
}
// RedeemedCoupons 传递被核销卡券信息的结构,
type RedeemedCoupons struct {
ExtraInfo string `json:"extrainfo,omitempty"`
Coupons []*Coupon `json:"coupons,omitempty"`
}
// TransType 卡券被操作的状态类型
type TransType int32
// 卡券被操作的种类
const (
TTIssueCoupon TransType = iota
TTDeactiveCoupon
TTDeleteCoupon
TTRedeemCoupon //可多次存在
TTExpired
TTUnknownTransaction
)
// Transaction 用来封装一次Coupon的状态变动
type Transaction struct {
ID string
CouponID string
ActorID string
TransType TransType
ExtraInfo string
CreatedTime time.Time
}
// EncryptExtraInfo 给ExtraInfo 使用AES256加密
func (t *Transaction) EncryptExtraInfo() string {
return utils.AES256URLEncrypt(t.ExtraInfo, encryptKey)
}
// DecryptExtraInfo 给ExtraInfo 使用AES256解密
func (t *Transaction) DecryptExtraInfo(emsg string) error {
p, e := utils.AES256URLDecrypt(emsg, encryptKey)
if nil != e {
return e
}
t.ExtraInfo = p
return nil
}
// CreatedTimeToLocal 给ExtraInfo 使用AES256解密
func (t *Transaction) CreatedTimeToLocal() {
l := t.CreatedTime.Local()
t.CreatedTime = l
}

View File

@ -0,0 +1,46 @@
package coupon
import (
"time"
)
// Rule 卡券的规则
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
InternalID string `json:"internal_id"`
Description string `json:"description,omitempty"`
RuleBody string `json:"rule_body"`
Creator string `json:"creator"`
CreatedTime time.Time `json:"created_time,omitempty" type:"DATETIME" default:"datetime('now','localtime')"`
UpdatedTime time.Time `json:"updated_time,omitempty" type:"DATETIME"`
DeletedTime time.Time `json:"deleted_time,omitempty" type:"DATETIME"`
}
type offsetSpan struct {
OffSetFromAppliedDay uint `json:"offSetFromAppliedDay"`
TimeSpan uint `json:"timeSpan"`
}
type timeSpan struct {
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
}
type natureTimeUnit struct {
Unit string `json:"unit"`
EndInAdvance int `json:"endInAdvance"`
}
type applyTimes struct {
InDays uint `json:"inDays"`
Times uint `json:"times"`
}
type redeemTimes struct {
Times uint `json:"times"`
}
type sameBrand struct {
Brand string `json:"brand"`
}

View File

@ -0,0 +1,237 @@
package coupon
import (
// "encoding/json"
// "fmt"
"log"
"github.com/mitchellh/mapstructure"
"loreal.com/dit/cmd/coupon-service/base"
)
var supportedRules []*Rule
var issueJudges map[string]TemplateJudge
var ruleBodyComposers map[string]BodyComposer
var redeemJudges map[string]Judge
// Init 初始化规则的一些基础数据
// TODO: 这些可以通过配置文件进行,避免未来修改程序后才能部署
func ruleInit(rules []*Rule) {
supportedRules = rules
issueJudges = make(map[string]TemplateJudge)
issueJudges["APPLY_TIMES"] = new(ApplyTimesJudge)
redeemJudges = make(map[string]Judge)
redeemJudges["REDEEM_PERIOD_WITH_OFFSET"] = new(RedeemPeriodJudge)
redeemJudges["REDEEM_TIMES"] = new(RedeemTimesJudge)
redeemJudges["REDEEM_BY_SAME_BRAND"] = new(RedeemBySameBrandJudge)
redeemJudges["REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR"] = new(RedeemInCurrentNatureTimeUnitJudge)
ruleBodyComposers = make(map[string]BodyComposer)
ruleBodyComposers["REDEEM_PERIOD_WITH_OFFSET"] = new(RedeemPeriodWithOffsetBodyComposer)
ruleBodyComposers["REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR"] = new(RedeemInCurrentNatureTimeUnitBodyComposer)
ruleBodyComposers["REDEEM_TIMES"] = new(RedeemTimesBodyComposer)
ruleBodyComposers["REDEEM_BY_SAME_BRAND"] = new(RedeemBySameBrandBodyComposer)
}
// ValidateTemplateRules 发券时验证是否可以领用
// 目前要求至少配置一个领用规则
func validateTemplateRules(consumerID string, couponTypeID string, pct *PublishedCouponType) ([]error, error) {
var errs []error = make([]error, 0)
var judgeTimes int = 0
for ruleInternalID, tempRuleBody := range pct.Rules {
var rule = findRule(ruleInternalID)
if nil == rule {
return nil, &ErrRuleNotFound
}
if judge, ok := issueJudges[ruleInternalID]; ok {
judgeTimes++
var err = judge.JudgeTemplate(consumerID, couponTypeID, tempRuleBody, pct)
if nil != err {
errs = append(errs, err)
}
continue
}
}
if 0 == judgeTimes {
return nil, &ErrRuleJudgeNotFound
}
return errs, nil
}
// ValidateCouponRules 在redeem时需要验证卡券的规则
// 目前要求至少配置一个兑换规则
func validateCouponRules(requester *base.Requester, consumerID string, c *Coupon) ([]error, error) {
var errs []error = make([]error, 0)
var judgeTimes int = 0
// var rulesString = c.Properties[KeyBindingRuleProperties]
// ruleBodyRefs, err := unmarshalCouponRules(rulesString)
// if nil != err {
// return nil, err
// }
// log.Println(c.GetRules())
// for ruleInternalID, ruleBody := range c.Properties[KeyBindingRuleProperties].(map[string]interface {}) {
for ruleInternalID, ruleBody := range c.GetRules() {
// TODO: 未来性能优化考虑这个findRule去掉
var rule = findRule(ruleInternalID)
if nil == rule {
return nil, &ErrRuleNotFound
}
if judge, ok := redeemJudges[ruleInternalID]; ok {
judgeTimes++
var err = judge.JudgeCoupon(requester, consumerID, ruleBody.(map[string]interface{}), c)
if nil != err {
errs = append(errs, err)
}
continue
}
}
// for _, ruleRef := range ruleBodyRefs {
// var ruleInternalID = ruleRef.RuleInternalID
// var ruleBody = ruleRef.RuleBody
// var rule = findRule(ruleInternalID)
// if nil == rule {
// return nil, &ErrRuleNotFound
// }
// if judge, ok := redeemJudges[rule.InternalID]; ok {
// judgeTimes++
// var err = judge.JudgeCoupon(requester, consumerID, ruleBody, c)
// if nil != err {
// errs = append(errs, err)
// }
// continue
// }
// }
if 0 == judgeTimes {
return nil, &ErrRuleJudgeNotFound
}
return errs, nil
}
// validateCouponExpired
func validateCouponExpired(requester *base.Requester, consumerID string, c *Coupon) (bool, error) {
for ruleInternalID, ruleBody := range c.GetRules() {
var rule = findRule(ruleInternalID)
if nil == rule {
return false, &ErrRuleNotFound
}
if _, ok := redeemJudges[ruleInternalID]; ok {
if ruleInternalID == "REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR" {
expired, err := JudgeNTUExpired(requester, consumerID, ruleBody.(map[string]interface{}), c)
return expired, err
}
if ruleInternalID == "REDEEM_PERIOD_WITH_OFFSET" {
expired, err := JudgePeriodExpired(requester, consumerID, ruleBody.(map[string]interface{}), c)
return expired, err
}
}
}
return false, &ErrRuleNotFound
}
func findRule(ruleInternalID string) *Rule {
for _, rule := range supportedRules {
if rule.InternalID == ruleInternalID {
return rule
}
}
return nil
}
// marshalCouponRules 发券时生成最终的规则字符串,用来在核销时验证
func marshalCouponRules(requester *base.Requester, couponTypeID string, rules map[string]map[string]interface{}) (map[string]interface{}, error) {
var ruleBodyMap map[string]interface{} = make(map[string]interface{}, 0)
for ruleInternalID, tempRuleBody := range rules {
var rule = findRule(ruleInternalID)
if nil == rule {
return nil, &ErrRuleNotFound
}
if composer, ok := ruleBodyComposers[ruleInternalID]; ok {
ruleBody, err := composer.Compose(requester, couponTypeID, tempRuleBody)
if nil != err {
log.Println(err)
return nil, err
}
ruleBodyMap[ruleInternalID] = ruleBody
}
}
return ruleBodyMap, nil
// jsonBytes, err := json.Marshal(ruleBodyRefs)
// if nil != err {
// log.Println(err)
// return "", err
// }
// return string(jsonBytes), nil
}
// unmarshalCouponRules 兑换时将卡券附带的规则解析成[]*RuleRef
// func unmarshalCouponRules(rulesString string) ([]*RuleRef, error) {
// var ruleBodyRefs []*RuleRef = make([]*RuleRef, 0)
// err := json.Unmarshal([]byte(rulesString), &ruleBodyRefs)
// if nil != err {
// log.Println(err)
// return nil, &ErrCouponRulesBadFormat
// }
// return ruleBodyRefs, nil
// }
// restRedeemTimes 查询剩下redeem的次数
func restRedeemTimes(c *Coupon) (uint, error) {
// for ruleInternalID, ruleBody := range c.Properties[KeyBindingRuleProperties].(map[string]interface {}) {
for ruleInternalID, ruleBody := range c.GetRules() {
if "REDEEM_TIMES" == ruleInternalID {
// var count uint
count, err := getCouponTransactionCountWithType(c.ID, TTRedeemCoupon)
if nil != err {
return 0, err
}
var rt redeemTimes
if err = mapstructure.Decode(ruleBody, &rt); err != nil {
return 0, &ErrCouponRulesBadFormat
}
// comparing first, to avoid negative result causes uint type out of bound
if uint(rt.Times) >= count {
return uint(rt.Times) - count, nil
}
return 0, nil
}
}
// TODO: 未来考虑此处查找优化,提升效率
// for _, ruleRef := range ruleBodyRefs {
// if ruleRef.RuleInternalID == "REDEEM_TIMES" {
// var ruleBody = ruleRef.RuleBody
// var rt redeemTimes
// err := json.Unmarshal([]byte(ruleBody), &rt)
// if err != nil {
// log.Println(err)
// return 0, err
// }
// // 查询已经兑换次数
// var count uint
// count, err = getCouponTransactionCountWithType(c.ID, TTRedeemCoupon)
// if nil != err {
// return 0, err
// }
// return rt.Times - count, nil
// }
// }
// 卡券没有设置兑换次数限制
return 0, &ErrCouponRulesNoRedeemTimes
}

View File

@ -0,0 +1,364 @@
package coupon
import (
// "encoding/json"
// "fmt"
// "log"
"reflect"
"testing"
"time"
"loreal.com/dit/cmd/coupon-service/base"
"bou.ke/monkey"
. "github.com/smartystreets/goconvey/convey"
)
func _aREDEEM_TIMES_RuleRef(times int) (string, map[string]interface{}) {
ruleBody := map[string]interface{}{
"times": times,
}
return "REDEEM_TIMES", ruleBody
}
func _aVAILD_PERIOD_WITH_OFFSET_RuleRef(offset int, span int) (string, map[string]interface{}) {
ruleBody := map[string]interface{}{
"offSetFromAppliedDay": offset,
"timeSpan": span,
}
return "REDEEM_PERIOD_WITH_OFFSET", ruleBody
}
func _aAPPLY_TIMES_RuleRef(indays int, times int) (string, map[string]interface{}) {
ruleBody := map[string]interface{}{
"inDays": indays,
"times": times,
}
return "APPLY_TIMES", ruleBody
}
func _aREDEEM_BY_SAME_BRAND_RuleRef() (string, map[string]interface{}) {
ruleBody := map[string]interface{}{}
return "REDEEM_BY_SAME_BRAND", ruleBody
}
func _someRules() map[string]map[string]interface{} {
var rrs = make(map[string]map[string]interface{}, 4)
rid, rbd := _aREDEEM_TIMES_RuleRef(1)
rrs[rid] = rbd
rid, rbd = _aVAILD_PERIOD_WITH_OFFSET_RuleRef(0, 100)
rrs[rid] = rbd
rid, rbd = _aAPPLY_TIMES_RuleRef(0, 1)
rrs[rid] = rbd
rid, rbd = _aREDEEM_BY_SAME_BRAND_RuleRef()
rrs[rid] = rbd
return rrs
}
func _aPublishedCouponType(rules map[string]map[string]interface{}) *PublishedCouponType {
var pct = PublishedCouponType{
ID: base.RandString(4),
Name: base.RandString(4),
TemplateID: base.RandString(4),
Description: base.RandString(4),
InternalDescription: base.RandString(4),
State: 0,
Publisher: base.RandString(4),
VisibleStartTime: time.Now().Local().AddDate(0, 0, -100),
VisibleEndTime: time.Now().Local().AddDate(0, 0, 100),
Rules: rules,
CreatedTime: time.Now().Local().AddDate(0, 0, -1),
}
return &pct
}
func Test_ruleInit(t *testing.T) {
Convey("validate rule init correctly", t, func() {
So(len(issueJudges), ShouldEqual, 1)
So(len(redeemJudges), ShouldEqual, 4)
So(len(ruleBodyComposers), ShouldEqual, 4)
})
}
func Test_validateTemplateRules(t *testing.T) {
Convey("Given a coupon will no rule errors", t, func() {
var pct = _aPublishedCouponType(_someRules())
var atJudge *ApplyTimesJudge
// atJudge = new(ApplyTimesJudge)
atJudge = issueJudges["APPLY_TIMES"].(*ApplyTimesJudge)
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(atJudge), "JudgeTemplate", func(_ *ApplyTimesJudge, _ string, _ string, _ map[string]interface{}, _ *PublishedCouponType) error {
return nil
})
Convey("Start validate", func() {
errs, err := validateTemplateRules("", "", pct)
Convey("Should no errors", func() {
So(err, ShouldBeNil)
So(len(errs), ShouldEqual, 0)
})
})
patchGuard.Unpatch()
})
Convey("Given such env with no match rules", t, func() {
var pct = _aPublishedCouponType(_someRules())
patchGuard := monkey.Patch(findRule, func(ruleInternalID string) *Rule {
return nil
})
Convey("Start validate", func() {
_, err := validateTemplateRules("", "", pct)
Convey("Should has ErrRuleNotFound", func() {
So(err, ShouldEqual, &ErrRuleNotFound)
})
})
patchGuard.Unpatch()
})
Convey("Given a template with no rules", t, func() {
var pct = _aPublishedCouponType(nil)
Convey("Start validate", func() {
_, err := validateTemplateRules("", "", pct)
Convey("Should has ErrRuleJudgeNotFound", func() {
So(err, ShouldEqual, &ErrRuleJudgeNotFound)
})
})
})
Convey("Given an env that some judge will fail", t, func() {
var pct = _aPublishedCouponType(_someRules())
var atJudge *ApplyTimesJudge
// atJudge = new(ApplyTimesJudge)
atJudge = issueJudges["APPLY_TIMES"].(*ApplyTimesJudge)
Convey("Assume ErrCouponRulesApplyTimeExpired", func() {
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(atJudge), "JudgeTemplate", func(_ *ApplyTimesJudge, _ string, _ string, _ map[string]interface{}, _ *PublishedCouponType) error {
return &ErrCouponRulesApplyTimeExpired
})
errs, _ := validateTemplateRules("", "", pct)
Convey("Should have ErrCouponRulesApplyTimeExpired", func() {
So(len(errs), ShouldEqual, 1)
So(errs[0], ShouldEqual, &ErrCouponRulesApplyTimeExpired)
})
patchGuard.Unpatch()
})
Convey("Assume ErrCouponRulesApplyTimesExceeded", func() {
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(atJudge), "JudgeTemplate", func(_ *ApplyTimesJudge, _ string, _ string, _ map[string]interface{}, _ *PublishedCouponType) error {
return &ErrCouponRulesApplyTimesExceeded
})
errs, _ := validateTemplateRules("", "", pct)
Convey("Should have ErrCouponRulesApplyTimesExceeded", func() {
So(len(errs), ShouldEqual, 1)
So(errs[0], ShouldEqual, &ErrCouponRulesApplyTimesExceeded)
})
patchGuard.Unpatch()
})
})
}
func Test_validateCouponRules(t *testing.T) {
Convey("Given a coupon with no rules", t, func() {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
p[KeyBindingRuleProperties] = map[string]interface{}{}
c := _aCoupon(base.RandString(4), "xxx", "yyy", defaultCouponTypeID, State(state), p)
// pg1 := monkey.Patch(unmarshalCouponRules, func(_ string)([]*RuleRef, error) {
// return make([]*RuleRef, 0), nil
// })
Convey("Start validate", func() {
_, err := validateCouponRules(nil, "", c)
Convey("Should has ErrRuleJudgeNotFound", func() {
So(err, ShouldEqual, &ErrRuleJudgeNotFound)
})
})
// pg1.Unpatch()
})
Convey("Given an env that some judge will fail", t, func() {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
var rrs = make(map[string]interface{}, 1)
rid, rbd := _aREDEEM_TIMES_RuleRef(999)
rrs[rid] = rbd
p[KeyBindingRuleProperties] = rrs
c := _aCoupon(base.RandString(4), "xxx", "yyy", defaultCouponTypeID, State(state), p)
// c.Properties = p
var rtJudge *RedeemTimesJudge
rtJudge = redeemJudges["REDEEM_TIMES"].(*RedeemTimesJudge)
Convey("Assume ErrCouponRulesRedeemTimesExceeded", func() {
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(rtJudge), "JudgeCoupon", func(_ *RedeemTimesJudge, _ *base.Requester, _ string, _ map[string]interface{}, _ *Coupon) error {
// log.Println("=======monkey.JudgeCoupon=====")
return &ErrCouponRulesRedeemTimesExceeded
})
// pg5 := monkey.PatchInstanceMethod(reflect.TypeOf(c), "GetRules", func(_ *Coupon) map[string]interface{} {
// log.Println("=======monkey.GetRules=====")
// return rrs
// })
errs, _ := validateCouponRules(nil, "", c)
// if nil != err {
// log.Println("=======err is not nil =====")
// log.Println(c.GetRules())
// log.Println(err)
// }
// pg5.Unpatch()
Convey("Should have ErrCouponRulesRedeemTimesExceeded", func() {
So(len(errs), ShouldEqual, 1)
So(errs[0], ShouldEqual, &ErrCouponRulesRedeemTimesExceeded)
})
patchGuard.Unpatch()
})
})
Convey("Given an env that everything is Okay", t, func() {
state := r.Intn(int(SUnknown))
c := _aCoupon(base.RandString(4), "xxx", "yyy", defaultCouponTypeID, State(state), nil)
// pg1 := monkey.Patch(unmarshalCouponRules, func(_ string)([]*RuleRef, error) {
// rrs := make([]*RuleRef, 0, 1)
// rrs = append(rrs, _aREDEEM_TIMES_RuleRef(1))
// return rrs, nil
// })
Convey("Start validate", func() {
var rtJudge *RedeemTimesJudge
rtJudge = redeemJudges["REDEEM_TIMES"].(*RedeemTimesJudge)
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(rtJudge), "JudgeCoupon", func(_ *RedeemTimesJudge, _ *base.Requester, _ string, _ map[string]interface{}, _ *Coupon) error {
return nil
})
errs, _ := validateCouponRules(nil, "", c)
Convey("Should no error", func() {
So(len(errs), ShouldEqual, 0)
})
patchGuard.Unpatch()
})
// pg1.Unpatch()
})
}
func Test_marshalCouponRules(t *testing.T) {
Convey("Given a rule will not be found", t, func() {
patchGuard := monkey.Patch(findRule, func(_ string) *Rule {
return nil
})
var rrs = make(map[string]map[string]interface{}, 4)
rid, rbd := _aREDEEM_TIMES_RuleRef(1)
rrs[rid] = rbd
Convey("Start marshal", func() {
_, err := marshalCouponRules(nil, "", rrs)
Convey("Should has ErrRuleNotFound", func() {
So(err, ShouldEqual, &ErrRuleNotFound)
})
})
patchGuard.Unpatch()
})
Convey("Given a composer should return error", t, func() {
var rrs = make(map[string]map[string]interface{}, 4)
rid, rbd := _aREDEEM_TIMES_RuleRef(1)
rrs[rid] = rbd
var rdbComposer *RedeemTimesBodyComposer
rdbComposer = ruleBodyComposers["REDEEM_TIMES"].(*RedeemTimesBodyComposer)
patchGuard := monkey.PatchInstanceMethod(reflect.TypeOf(rdbComposer), "Compose", func(_ *RedeemTimesBodyComposer, _ *base.Requester, _ string, _ map[string]interface{}) (map[string]interface{}, error) {
// just pick an error for test, don't care if it is logical
return nil, &ErrRequesterHasNoBrand
})
Convey("Start marshal", func() {
_, err := marshalCouponRules(nil, "", rrs)
Convey("Should has ErrRequesterHasNoBrand", func() {
So(err, ShouldEqual, &ErrRequesterHasNoBrand)
})
})
patchGuard.Unpatch()
})
Convey("Given an env that everything is Okay", t, func() {
var rrs = make(map[string]map[string]interface{}, 4)
rid, rbd := _aREDEEM_TIMES_RuleRef(1)
rrs[rid] = rbd
Convey("Start marshal", func() {
m, err := marshalCouponRules(nil, "", rrs)
Convey("Should has correct result", func() {
So(err, ShouldBeNil)
So(m["REDEEM_TIMES"], ShouldNotBeNil)
})
})
})
}
// func Test_unmarshalCouponRules(t *testing.T) {
// Convey("Given a rule string with bad format (non json format)", t, func() {
// _, err := unmarshalCouponRules("bad_format")
// So(err, ShouldEqual, &ErrCouponRulesBadFormat)
// })
// Convey("Given a rule string with not RuleRef format", t, func() {
// _, err := unmarshalCouponRules(`{"hello" : "world"}`)
// So(err, ShouldEqual, &ErrCouponRulesBadFormat)
// })
// Convey("Given a rule string with RuleRef format", t, func() {
// rrfs, err := unmarshalCouponRules(` [ {"rule_id":"REDEEM_TIMES","rule_body":"abc"}]`)
// So(err, ShouldBeNil)
// So(len(rrfs), ShouldEqual,1)
// rrf := rrfs[0]
// So(rrf.RuleInternalID, ShouldEqual, "REDEEM_TIMES")
// So(rrf.RuleBody, ShouldEqual, "abc")
// })
// }
func Test_restRedeemTimes(t *testing.T) {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
var rrs = make(map[string]interface{}, 4)
rid, rbd := _aREDEEM_TIMES_RuleRef(10000000)
rrs[rid] = rbd
p[KeyBindingRuleProperties] = rrs
c := _aCoupon(base.RandString(4), "xxx", "yyy", defaultCouponTypeID, State(state), p)
Convey("Given a env assume the db is down", t, func() {
pg1 := monkey.Patch(getCouponTransactionCountWithType, func(_ string, _ TransType) (uint, error) {
return 0, &ErrCouponRulesBadFormat
})
_, err := restRedeemTimes(c)
So(err, ShouldEqual, &ErrCouponRulesBadFormat)
pg1.Unpatch()
})
Convey("Given a env assume the coupon had been redeemed n times", t, func() {
ts := r.Intn(10000)
pg1 := monkey.Patch(getCouponTransactionCountWithType, func(_ string, _ TransType) (uint, error) {
return uint(ts), nil
})
restts, err := restRedeemTimes(c)
So(err, ShouldBeNil)
So(restts, ShouldEqual, 10000000-ts)
pg1.Unpatch()
})
}

View File

@ -0,0 +1,11 @@
package coupon
import (
"database/sql"
)
var dbConnection *sql.DB
func staticsInit(databaseConnection *sql.DB) {
dbConnection = databaseConnection
}

View File

@ -0,0 +1,98 @@
[
{
"name": "A",
"id": "63f9f1ce-2ad0-462a-b798-4ead5e5ab3a5",
"template_id": "dba25cb3-4ad9-44c2-8815-6d8b12c10f5a",
"description": "普通的case",
"internal_description":"这是发布模板的内部描述",
"state": 0,
"publisher":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"visible_start_time":"2019-01-01T00:00:00+08:00" ,
"visible_end_time":"2030-01-31T23:59:59+08:00",
"created_time":"2019-12-12T15:12:12+08:00" ,
"deleted_time":null,
"rules":
{
"REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR":"{ \"unit\": \"MONTH\", \"endInAdvance\": 0 }",
"APPLY_TIMES":"{ \"inDays\": 0, \"times\": 1 }",
"REDEEM_TIMES":"{\"times\": 1}"
}
},
{
"name": "B",
"id": "58b388ff-689e-445a-8bbd-8d707dbe70ef",
"template_id": "dba25cb3-4ad9-44c2-8815-6d8b12c10f5a",
"description": "一些稍微特别的case, 可以申请2次核销2次下个月还可以兑换",
"internal_description":"这是发布模板的内部描述",
"state": 0,
"publisher":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"visible_start_time":"2019-01-01T00:00:00+08:00" ,
"visible_end_time":"2030-01-31T23:59:59+08:00",
"created_time":"2019-12-12T15:12:12+08:00" ,
"deleted_time":null,
"rules":
{
"APPLY_TIMES":"{ \"inDays\": 0, \"times\": 2 }",
"REDEEM_TIMES":"{\"times\": 2}",
"REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR":"{ \"unit\": \"MONTH\", \"endInAdvance\": -31 }",
"REDEEM_BY_SAME_BRAND":"{\"brand\": \"\"}"
}
},
{
"name": "C",
"id": "abd73dbe-cc91-4b61-b10c-c6532d7a7770",
"template_id": "dba25cb3-4ad9-44c2-8815-6d8b12c10f5a",
"description": "永远也不能被兑换",
"internal_description":"这是发布模板的内部描述",
"state": 0,
"publisher":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"visible_start_time":"2019-01-01T00:00:00+08:00" ,
"visible_end_time":"2030-01-31T23:59:59+08:00",
"created_time":"2019-12-12T15:12:12+08:00" ,
"deleted_time":null,
"rules":
{
"APPLY_TIMES":"{ \"inDays\": 0, \"times\": 300 }",
"REDEEM_TIMES":"{\"times\": 3}",
"REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR":"{ \"unit\": \"MONTH\", \"endInAdvance\": 31 }"
}
},
{
"name": "D",
"id": "ca0ff68f-dc05-488d-b185-660b101a1068",
"template_id": "dba25cb3-4ad9-44c2-8815-6d8b12c10f5a",
"description": "延迟几天天兑换",
"internal_description":"这是发布模板的内部描述",
"state": 0,
"publisher":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"visible_start_time":"2019-01-01T00:00:00+08:00" ,
"visible_end_time":"2030-01-31T23:59:59+08:00",
"created_time":"2019-12-12T15:12:12+08:00" ,
"deleted_time":null,
"rules":
{
"REDEEM_IN_CURRENT_NATURE_MONTH_SEASON_YEAR":"{ \"unit\": \"MONTH\", \"endInAdvance\": -10 }",
"APPLY_TIMES":"{ \"inDays\": 0, \"times\": 20000 }",
"REDEEM_TIMES":"{\"times\": 200000}"
}
},
{
"name": "E",
"id": "7c0fb6b2-7362-4933-ad15-cd4ad9fccfec",
"template_id": "dba25cb3-4ad9-44c2-8815-6d8b12c10f5a",
"description": "延迟几天天兑换",
"internal_description":"这是发布模板的内部描述",
"state": 0,
"publisher":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"visible_start_time":"2019-01-01T00:00:00+08:00" ,
"visible_end_time":"2030-01-31T23:59:59+08:00",
"created_time":"2019-12-12T15:12:12+08:00" ,
"deleted_time":null,
"rules":
{
"REDEEM_PERIOD_WITH_OFFSET": "{\"offSetFromAppliedDay\": 0,\"timeSpan\": 365}",
"APPLY_TIMES":"{ \"inDays\": 0, \"times\": 20000 }",
"REDEEM_TIMES":"{\"times\": 200000}"
}
}
]

View File

@ -0,0 +1,48 @@
[
{
"id": "f5d58d56-49ca-4df3-83f9-df9d4ea59974",
"name": "使用次数限制",
"internal_id":"REDEEM_TIMES",
"description": "卡券使用次数限制,比如领用后,只能使用一次,默认一次。",
"rule_body":"{\"times\": 1}",
"creator":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"created_time":"2019-12-12T15:12:12+08:00" ,
"updated_time":null,
"deleted_time": null
},
{
"id": "b3df26d8-39c8-496c-8299-5da6febb1c60",
"name": "开始日期可偏移的生效时间",
"internal_id":"REDEEM_PERIOD_WITH_OFFSET",
"description": "卡券申领后的生效日期。offSetFromAppliedDay是相对申领日期的延后日期单位为天。timeSpan是生效时间跨度。-1为没有到期就失效的时间。举例offSetFromAppliedDay = 14timeSpan 用户在2020年1月10日领取那么在2020年1月23日生效可兑换2021年23日过期",
"rule_body": "{\"offSetFromAppliedDay\": 0,\"timeSpan\": 365}",
"creator":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"created_time":"2019-12-12T15:12:12+08:00",
"updated_time":null,
"deleted_time":null
},
{
"id": "1aabf6e1-7b7f-4c72-8526-1180eeaf0ef4",
"name": "在一定期限内的领用次数限制",
"internal_id":"APPLY_TIMES",
"description": "卡券申领次数限制可以设置在若干天内的申请次数也意味这卡券开放申请的时间限制。inDays为0时则不限领用过期时间。当inDays为99时则卡券发布后的99天内可以申领。",
"rule_body":"{ \"inDays\": 365, \"times\": 1 }",
"creator":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"created_time": "2019-12-12T15:12:12+08:00",
"updated_time": null,
"deleted_time": null
},
{
"id": "f2b2025f-2e78-483c-b414-2935146e6d05",
"name": "限制在同品牌兑换",
"internal_id":"REDEEM_BY_SAME_BRAND",
"description": "核销时校验核销者所属的品牌是否和签发者所属的品牌一致,如果否,则不允许核销",
"rule_body":"{\"brand\": \"\"}",
"creator":"ff27204e-6ef2-48e2-a437-7e48cc49d659",
"created_time": "2020-01-06T15:12:12+08:00",
"updated_time": null,
"deleted_time": null
}
]

View File

@ -0,0 +1,116 @@
package coupon
import (
"database/sql"
"os"
// "fmt"
"math/rand"
// "reflect"
"testing"
"time"
"loreal.com/dit/cmd/coupon-service/base"
"loreal.com/dit/utils"
"github.com/google/uuid"
migrate "github.com/rubenv/sql-migrate"
)
var r *rand.Rand = rand.New(rand.NewSource(time.Now().Unix()))
const defaultCouponTypeID string = "678719f5-44a8-4ac8-afd0-288d2f14daf8"
const anotherCouponTypeID string = "dff0710e-f5af-4ecf-a4b5-cc5599d98030"
func TestMain(m *testing.M) {
_setUp()
m.Run()
_tearDown()
}
func _setUp() {
encryptKey = []byte("a9ad231b0f2a4f448b8846fd1f57813a")
err := os.Remove("../data/testdata.sqlite")
dbConnection, err = sql.Open("sqlite3", "../data/testdata.sqlite?cache=shared&mode=rwc")
if err != nil {
panic(err)
}
migrations := &migrate.FileMigrationSource{
Dir: "../sql-migrations",
}
_, err = migrate.Exec(dbConnection, "sqlite3", migrations, migrate.Up)
if err != nil {
panic(err)
}
dbInit()
utils.LoadOrCreateJSON("./test/rules.json", &supportedRules)
ruleInit(supportedRules)
}
func _tearDown() {
os.Remove("./data/testdata.sqlite")
dbConnection.Close()
}
func _aCoupon(consumerID string, consumerRefID string, channelID string, couponTypeID string, state State, p map[string]interface{}) *Coupon {
lt := time.Now().Local()
var c = Coupon{
ID: uuid.New().String(),
CouponTypeID: couponTypeID,
ConsumerID: consumerID,
ConsumerRefID: consumerRefID,
ChannelID: channelID,
State: state,
Properties: p,
CreatedTime: &lt,
}
return &c
}
func _someCoupons(consumerID string, consumerRefID string, channelID string, couponTypeID string) []*Coupon {
count := r.Intn(10) + 1
cs := make([]*Coupon, 0, count)
for i := 0; i < count; i++ {
state := r.Intn(int(SUnknown))
var p map[string]interface{}
p = make(map[string]interface{}, 1)
p["the_key"] = "the value"
cs = append(cs, _aCoupon(consumerID, consumerRefID, channelID, couponTypeID, State(state), p))
}
return cs
}
func _aTransaction(actorID string, couponID string, tt TransType, extraInfo string) *Transaction {
var t = Transaction{
ID: uuid.New().String(),
CouponID: couponID,
ActorID: actorID,
TransType: tt,
ExtraInfo: extraInfo,
CreatedTime: time.Now().Local(),
}
return &t
}
func _someTransaction(actorID string, couponID string, extraInfo string) []*Transaction {
count := r.Intn(10) + 1
ts := make([]*Transaction, 0, count)
for i := 0; i < count; i++ {
tt := r.Intn(int(TTUnknownTransaction))
ts = append(ts, _aTransaction(actorID, couponID, TransType(tt), extraInfo))
}
return ts
}
func _aRequester(userID string, roles []string, brand string) *base.Requester {
var requester base.Requester
requester.UserID = userID
requester.Roles = map[string]([]string){
"roles": roles,
}
requester.Brand = brand
return &requester
}

View File

@ -0,0 +1,42 @@
package main
import (
"database/sql"
"fmt"
"log"
"github.com/gobuffalo/packr/v2"
_ "github.com/mattn/go-sqlite3"
migrate "github.com/rubenv/sql-migrate"
)
//InitDB - initialized database
func (a *App) InitDB() {
//init database tables
var err error
for _, env := range a.Runtime {
env.db, err = sql.Open("sqlite3", fmt.Sprintf("%s%s?cache=shared&mode=rwc", env.Config.DataFolder, env.Config.SqliteDB))
if err != nil {
log.Fatal(err)
}
log.Printf("[INFO] - applying database [%s] migrations...\n", env.Config.Name)
// migrations := &migrate.FileMigrationSource{
// Dir: "sql-migrations",
// }
migrations := &migrate.PackrMigrationSource{
Box: packr.New("sql-migrations", "./sql-migrations"),
}
n, err := migrate.Exec(env.db, "sqlite3", migrations, migrate.Up)
if err != nil {
log.Fatal(err)
}
log.Printf("[INFO] - [%d] migration file applied\n", n)
log.Printf("[INFO] - DB for [%s] ready!\n", env.Config.Name)
}
}

View File

@ -0,0 +1,485 @@
# Larry的工作内容交接
## 卡券服务
可以结合intergartion guide for third-parties一起阅读关于卡券的内容。
### 源代码库
https://github.com/iamhubin/loreal.com/tree/master/dit/cmd/coupon-service 目前已经做了转移给 **@iamhubin** 等待approve
### 源代码结构
├── Dockerfile 制作编译卡券服务的docker 镜像
├── app.go app对象包含卡券服务的一些初始化和各种配置
├── base
│ ├── baseerror.go 卡券服务错误的结构定义
│ ├── baseerror_test.go
│ ├── config.default.go 服务的默认配置
│ ├── config.go 服务配置的数据结构
│ ├── lightutils.go 轻量的工具函数
│ ├── lightutils_test.go
│ └── requester.go 封装了请求者的信息,比如角色,品牌等
├── config
│ ├── accounts.json 暂未用到hubin之前的代码遗留
│ └── config.json 程序的运行时配置,将会覆盖默认配置
├── coupon
│ ├── db.coupon.go 卡券服务的dao层
│ ├── db.coupon_test.go
│ ├── errors.go 卡券服务的各种错误定义
│ ├── logic.coupon.go 卡券结构的一些方法
│ ├── logic.couponservice.go 卡券服务的一些方法,比如创建卡券,查询卡券,核销卡券
│ ├── logic.couponservice_test.go
│ ├── logic.judge.go 规则校验者的定义,如果返回错误,则校验失败。
│ ├── logic.judge_test.go
│ ├── logic.rulecomposer.go 签发卡券时,生成卡券的规则体,比如核销有效时间
│ ├── logic.rulecomposer_test.go
│ ├── message.go 核销卡券时,可以通知一些相关方核销的信息,这是消息结构。
│ ├── module.coupon.go 卡券以及卡券类型的数据结构
│ ├── module.rule.go 规则以及各个规则细节的结构
│ ├── ruleengine.go 规则引擎,统一调用各个规则体生产规则字符串,以及调用规则校验者校验卡券
│ ├── ruleengine_test.go
│ ├── statics.go 一些常量
│ ├── test
│ │ ├── coupon_types.json 定义一些卡券类型用来api测试的。关于api测试参考后面章节
│ │ └── rules.json 用于单元测试的一些规则
│ └── testbase_test.go
├── data
│ ├── data.db 运行时的sqlite数据库api测试也会用这个数据库
│ └── testdata.sqlite 单元测试时的数据库
├── db.go 初始化数据库以及每次启动时执行升级脚本
├── docs
│ ├── authorization\ server\ handbook.md 认证服务器手册
│ ├── context\ of\ coupon\ service.md 部署目标服务器的上下文环境
│ ├── go-live\ handbook.md 上线手册
│ ├── intergartion\ guide\ for\ third-parties.md 第三方开发手册
│ └── technical\ and\ functional\ specification.md 卡券服务功能/技术规格
├── endpoints.debug.go
├── endpoints.gateway.go 暂未涉及hubin之前的代码遗留
├── endpoints.go 服务的http入口定义以及做为api成对一些参数进行校验
├── logic.db.go 暂未涉及hubin之前的代码遗留
├── logic.gateway.go 暂未涉及hubin之前的代码遗留
├── logic.gateway.upstream.token.go 暂未涉及hubin之前的代码遗留
├── logic.gateway_test.go
├── logic.go 暂未涉及hubin之前的代码遗留
├── logic.task.maintenance.go 暂未涉及hubin之前的代码遗留
├── main.go 主函数入口,载入资源,程序初始化。
├── makefile
├── message.go 暂未涉及hubin之前的代码遗留
├── model.brand.go 暂未涉及hubin之前的代码遗留
├── model.const.go 暂未涉及hubin之前的代码遗留
├── model.go 暂未涉及hubin之前的代码遗留
├── module.predefineddata.go 因为卡券类型尚未开发这里hardcode一些初始化的卡券类型数据。
├── net.config.go 暂未涉及hubin之前的代码遗留
├── oauth
│ └── oauthcheck.go 校验requester的token
├── pre-defined
│ └── predefined-data.json hardcode的卡券类型数据以及规则数据。
├── restful 该文件夹下的文件暂未涉及hubin之前的代码遗留
├── sql-migrations
│ └── init-20191213144434.sql 数据库升级脚本
└── task.register.go 暂未涉及hubin之前的代码遗留
### 重要源代码文件列表
#### endpoints.go
定义了api入口部分采用了restful风格方法内会对输入参数进行接口层的校验。
```go
func (a *App) initEndpoints() {
rt := a.getRuntime("prod")
a.Endpoints = map[string]EndpointEntry{
"api/kvstore": {Handler: a.kvstoreHandler, Middlewares: a.noAuthMiddlewares("api/kvstore")},
"api/visit": {Handler: a.pvHandler},
"error": {Handler: a.errorHandler, Middlewares: a.noAuthMiddlewares("error")},
"debug": {Handler: a.debugHandler},
"maintenance/fe/upgrade": {Handler: a.feUpgradeHandler},
"api/gw": {Handler: a.gatewayHandler},
"api/events/": {Handler: longPollingHandler},
"api/coupontypes": {Handler: couponTypeHandler},
"api/coupons/": {Handler: couponHandler},
"api/redemptions": {Handler: redemptionHandler},
"api/apitester": {Handler: apitesterHandler},
}
postPrepareDB(rt)
}
```
#### db.go
下面的代码段是打包数据库升级脚本文件以及执行升级脚本的代码。
注意在mac和windows成功执行了从打包文件中读取脚本但CentOS没有成功所以目前是手动拷贝的。
```go
migrations := &migrate.PackrMigrationSource{
Box: packr.New("sql-migrations", "./sql-migrations"),
}
n, err := migrate.Exec(env.db, "sqlite3", migrations, migrate.Up)
```
#### sql-migrations/init-20191213144434.sql
这是初始数据库升级脚本。
注意:升级脚本一旦发布,只可增加,不可修改。
#### pre-defined/predefined-data.json
因为卡券类型模块用户可以通过api创建卡券类型尚未开发所以目前是根据业务的需要hard code卡券类型到这里。代码中的第一个卡券类型是测试用。其他6个是正式的卡券。
#### coupon/ruleengine.go
创建卡券时,规则引擎将会检查用户是否可以创建,如果可以,这里会生成各种规则体,附加到卡券上。
核销卡券是,规则引擎检查是否可以核销。
#### coupon/module.rule.go
规则结构,用来描述一个规则,比如核销几次。
#### coupon/logic.rulecomposer.go
rule composer将会根据卡券类型中配置的规则来生成某个规则的规则体比如
```json
"REDEEM_TIMES": {
"times": 3
}
```
表示可以核销3次。
注意卡券结构中的规则体是json格式字符串。
#### coupon/logic.judge.go
judge是每个规则的校验者如果有问题就返回错误。
比如卡券超兑,会返回 ErrCouponRulesRedeemTimesExceeded
```json
{
"error-code": 1006,
"error-message": "coupon redeem times exceeded"
}
```
#### coupon/logic.couponservice.go
相当于传统3层架构的业务层主要处理卡券相关的业务签发查询核销等。
#### coupon/db.coupon.go
相当于传统3层架构的数据层
#### base/requester.go
表示api请求者身份的。
### 重要的数据结构
#### Rule
rule是描述一个规则因为尚未开发卡券类型模块没有对应的数据库表。
这里的结构可以映射为一个数据库表。
其中InternalID是uniqu human readable字符串比如 REDEEM_TIMES 表示核销次数规则。
RuleBody是一个json格式的字符串。未来在数据库中应该是一个字符串。
```go
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
InternalID string `json:"internal_id"`
Description string `json:"description,omitempty"`
RuleBody string `json:"rule_body"`
Creator string `json:"creator"`
CreatedTime time.Time `json:"created_time,omitempty" type:"DATETIME" default:"datetime('now','localtime')"`
UpdatedTime time.Time `json:"updated_time,omitempty" type:"DATETIME"`
DeletedTime time.Time `json:"deleted_time,omitempty" type:"DATETIME"`
}
```
#### Template
这是一个卡券的原始模板,品牌可以根据末班创建自己的卡券类型。
Creator是创建者未来可以用于访问控制。
Rules是一个map保存若干规则参见 pre-defined/predefined-data.json
```go
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Creator string `json:"creator"`
Rules map[string]interface{} `json:"rules"`
CreatedTime time.Time `json:"created_time,omitempty" type:"DATETIME" default:"datetime('now','localtime')"`
UpdatedTime time.Time `json:"updated_time,omitempty" type:"DATETIME"`
DeletedTime time.Time `json:"deleted_time,omitempty" type:"DATETIME"`
}
```
#### PublishedCouponType
这是根据Template创建的卡券类型。前台系统可以根据卡券类型签发卡券。
TemplateID是基于卡券模板。
Publisher 发布者,未来可以据此进行访问控制。
StrRules 字符串类型的规则。
Rules是 struct类型的规则用于系统内部处理业务。
```go
type PublishedCouponType struct {
ID string `json:"id"`
Name string `json:"name"`
TemplateID string `json:"template_id"`
Description string `json:"description"`
InternalDescription string `json:"internal_description"`
State CTState `json:"state"`
Publisher string `json:"publisher"`
VisibleStartTime time.Time `json:"visible_start_time" type:"DATETIME"`
VisibleEndTime time.Time `json:"visible_end_time"`
StrRules map[string]string `json:"rules"`
Rules map[string]map[string]interface{}
CreatedTime time.Time `json:"created_time" type:"DATETIME" default:"datetime('now','localtime')"`
DeletedTime time.Time `json:"deleted_time" type:"DATETIME"`
}
```
#### Coupon
描述一个卡券。
CouponTypeID是PublishedCouponType的ID.
请参考intergartion guide for third-parties 了解更多
```go
type Coupon struct {
ID string
CouponTypeID string
ConsumerID string
ConsumerRefID string
ChannelID string
State State
Properties map[string]interface{}
CreatedTime *time.Time
}
```
#### Transaction
用户针对卡券做一个操作后Transaction将描述这一行为。
ActorID是操作者的id。
TransType是操作类型。
ExtraInfo 是操作者附加的信息,用于后期获取后处理前台业务。
```go
type Transaction struct {
ID string
CouponID string
ActorID string
TransType TransType
ExtraInfo string
CreatedTime time.Time
}
```
### 重要的接口
#### TemplateJudge
签发卡券时,验证卡券模板。
```go
// TemplateJudge 发卡券时用来验证是否符合rules
type TemplateJudge interface {
// JudgeTemplate 验证模板
JudgeTemplate(consumerID string, couponTypeID string, ruleBody map[string]interface{}, pct *PublishedCouponType) error
}
```
#### Judge
核销卡券时,验证卡券。
```go
// Judge 兑换卡券时用来验证是否符合rules
type Judge interface {
// JudgeCoupon 验证模板
JudgeCoupon(requester *base.Requester, consumerID string, ruleBody map[string]interface{}, c *Coupon) error
}
```
#### BodyComposer
签发卡券时,生成规则的规则体,用于附加在卡券上。
```go
// BodyComposer 发卡时生成rule的body用来存在coupon中
type BodyComposer interface {
Compose(requester *base.Requester, couponTypeID string, ruleBody map[string]interface{}) (map[string]interface{}, error)
}
```
### 单元测试
目前代码中针对coupon文件夹下增加了若干单元测试。
#### 知识准备
阅读单元测试,请先了解:
github.com/smartystreets/goconvey/convey 这是一个可以使用易于描述的方式组织单元测试结构。
bou.ke/monkey 这是一个mock方法的第三方库。
#### 运行单元测试
命令行进入 /coupon 执行
```sh
go test -gcflags=-l
```
因为内联函数的缘故,需要加上 -gcflags=-l
### API测试
目前代码中针对coupon相关的api增加了若干测试。
代码路径: https://github.com/iamhubin/loreal.com/tree/master/dit/cmd/api-tests-for-coupon-service
#### 知识准备
阅读API测试请先了解
github.com/smartystreets/goconvey/convey 这是一个可以使用易于描述的方式组织单元测试结构。
github.com/gavv/httpexpect 这是一个api调用并且可以验证结果的第三方库。
#### 执行测试
命令行进入 /api-tests-for-coupon-service 执行
```sh
go test
```
### 相关文档
请参阅 https://github.com/iamhubin/loreal.com/tree/master/dit/cmd/coupon-service/docs
### 其他文档
文档压缩包中包含如下几个文件:
| 文件名 | 备注 | |
| ----------------------------------------------------------- | --------------------------------------- | ---- |
| 02_卡券类型申请表单_入会礼_0224.xlsx | dennis提交的入会礼卡券 | |
| 副本03_客户端申请表单_线上渠道_0219.xlsx | dennis提交的客户端 | |
| 副本05_用户申请表单_Yiyun_0219.xlsx | dennis提交的用户 | |
| 卡券类型申请表单_生日礼_0224.xlsx | dennis提交的生日礼卡券 | |
| 02_Web Application Criticality Determination Form_0219.xlsx | pt测试申请表单 | |
| 03_Penetration Test Request Form_0219.xlsx | pt测试申请表单 | |
| 卡券服务组件架构图.vsdx | 早期文档,用处不大。 | |
| 卡券中心最简MVP实施计划.xlsx | larry个人做了一点记录用处不大 | |
| CardServiceDBSchema.graphml | 数据库设计用处不大建议看代码中的DDL | |
| Loreal卡券服务思维导图.pdf | 早期构思卡券服务功能时的思维导图 | |
| data flow diagram.pdf | pt测试需要的数据流图 | |
| network architecture.pdf | pt测试需要的网络架构图 | |
## oAuth2认证服务
认证服务采用了https://hub.docker.com/r/jboss/keycloak。
数据库是https://hub.docker.com/_/mysql。
启动服务的关键命令如下:
```sh
#创建docker的虚拟网络
sudo docker network create keycloak-network
#启动mysql,注意参数,这不是产线环境参数。
docker run --name mysql -d --net keycloak-network -e MYSQL_DATABASE=keycloak -e MYSQL_USER=keycloak -e MYSQL_PASSWORD=password -e MYSQL_ROOT_PASSWORD=root_password mysql
#启动keycloak,注意参数,这不是产线环境参数。
docker run --name keycloak --net keycloak-network -p 8080:8080 -e KEYCLOAK_USER=yhl10000 -e KEYCLOAK_PASSWORD=Passw0rd jboss/keycloak
```
如何使用认证服务请参阅:卡券服务-相关文档章节。
## 开发测试环境
开发测试环境的服务器从兰伯特那边接过来的。
服务地址https://gua.e-loreal.cn/#/
服务登录方式请询问hubin。
认证服务请docker ps 相关容器。
卡券服务目录: /home/larryyu/coupon-service。
关于目录结构以及相关功能请咨询hubin。
卡券服务测试服务器是否启动请访问http://52.130.73.180/ceh/cvc/health
认证服务管理入口http://52.130.73.180/auth/
## SIT集成测试环境
SIT集成环境用来给供应商开发测试用。
服务登录方式请询问hubin。
服务器有两台10.162.66.29 和 10.162.66.30 。
卡券服务器目前只用了一台10.162.66.29类似开发测试环境包含了认证和卡券两个服务。服务器登录账号目前用的是arvato的账号 **arvatoadmin** 密码是【请询问hubin】
认证服务请docker ps 相关容器。
卡券服务目录: /home/arvatoadmin/coupon-service。
卡券服务测试服务器是否启动请访问https://dl-api-uat.lorealchina.com/ceh/cvc/health
认证服务管理入口跳板机内配置bitvise后浏览器访问 http://10.162.66.29/auth/
## PRD产线环境
服务登录方式请询问hubin。
服务器有两台:
10.162.65.217 :认证服务器
10.162.65.218 :卡券服务器
服务器登录账号目前用的是**appdmin** 密码是【请询问hubin】
认证服务请docker ps 相关容器。
注意:认证服务数据库在/data1t
卡券服务目录: /home/appadmin/coupon-service。
注意:卡券数据库在/data1t

View File

@ -0,0 +1,210 @@
# 用户认证服务上线/运营手册
v 0.0.1
by 欧莱雅IT
## 修订历史
| 版本 | 修订说明 | 提交人 | 生效日期 |
| ----- | -------------- | -------- | -------- |
| 0.0.1 | 初始化创建文档 | Larry Yu | |
| | | | |
[TOC]
## 引言
### 目前的部署情况
SIT 环境https://dl-api-uat.lorealchina.com/auth/realms/Lorealcn/protocol/openid-connect/token
PRD 环境https://dl-api.lorealchina.com/auth/realms/Lorealcn/protocol/openid-connect/token
### 目的
用户认证服务将会提供欧莱雅内部用户的账号维护,以及为依赖用户认证服务的应用签发令牌和核验令牌。
为方便相关人员理解系统以及如何操作,本文档介绍如何上线部署用户认证服务,以及后期运营。
### 准备工作
如果阅读者是部署人员需要了解dockermysqllinux环境。
如果是配置人员需要了解oAuth2。
## 部署
请参考 go-live handbook
【注意】部署时,注意初始化管理员账号。
部署成功后,可以打开{host}/auth/ 来测试是否部署成功。
## 导入realm[可选]
因为在测试环境已经创建了realm为了简化操作可以直接导入已经存在的realm。
## 配置realm
如果没有导入一个现有的realm那么需要创建一个新的。
### Login 标签页,
- 配置用email登录
- 外部请求使用SSL
- 其他可以关闭
### Keys标签页
查看RS256的公约用来给卡券服务作为认证之用。
### Tokens标签页
一般默认就可,除非特别配置。
### 导出配置
为了方便管理以及迁移数据管理员可以有限导出realm的数据。包括
| 项目 | 描述 | |
| -------- | ------------------------------------------------------------ | ---- |
| 组和角色 | 根据业务的需要,可以创建一些组,比如具有相同角色的人可以放在一个组里面。<BR>角色用来描述一个用户可以做什么事情。 | |
| 客户端 | 客户端是用来描述一个接入oAuth2服务的程序或者服务。比如欧莱雅内部campaign tool。<BR>客户端功能可以有效区分不同的应用,分别配置访问资源的权限,更大可能保护用户的资源等。 | |
| | | |
注意:**管理员无法导出用户信息。**
## 管理Clients
这里的客户端也就是应用目前我们有campaign tool和云积分也就是说至少有两个客户端需要配置。
原则上为了安全一个单独的服务就是一个client。
### 配置Tab
- 配置Client ID,将会交付给应用开发商。
- Enabled标志应用是否被启用。
- Consent required 选择False. 【注意】因为用户是欧莱雅内部员工所以此处不需要Consent这不是常用的选择。
- Client Protocol 选择 openid-connect。
- Access Type 选择 confidential。
- Authorization Enable 选择false。
### Crendentias Tab
Client Authenticator 选择 client id and secret, 然后生成一个secret。【**注意**】**<u>这个secret是保密内容请使用安全的方式交付给应用开发商</u>**。
### Mappers Tab
这里为一个client配置一个mapper将会在用户的token里增加一些项比如用户所属的品牌信息。
新建一个mapper取一个有意义的名字比如**用户所属品牌**
打开mapper编辑各个属性
- Protocol 选择openid-connect
- Mapper Type :选择 User Attribute
- User Attribute **brand**,【 注意】这是预先定义好的,不要修改成其他的。
- Token Claim Name **brand**,【 注意】这是预先定义好的,不要修改成其他的。
- Claim JSON Type String
- Add to ID token 选择ON
- Add to access token : 选择ON
- Add to userinfo : 选择ON
## 角色
新建三个角色,如下表:
| 角色名 | 用途 | 备注 |
| --------------- | -------------------------------------- | ------------------------------------------------------------ |
| coupon_issuer | 可以签发卡券 | 比如campaign tool需要发券那么内置的用户需要这个角色 |
| coupon_listener | 可以监听卡券服务的事件,比如核销事件。 | 比如campaign tool想得知哪个消费者核销了哪个券可以配置这个角色然后长轮询核销卡券的事件。 |
| coupon_redeemer | 可以核销卡券 | 比如云积分需要发券,那么内置的用户需要这个角色 |
## 组Groups
因为有一些业务需求是不允许夸品牌兑换,通过配置组可以实现用户品牌的区分。
用户认证服务里面的组相当于欧莱雅的品牌。
比如新建组:**兰蔻** 。然后在**属性Tab**里面增加一个属性:
| Key | Value | 备注 |
| :---- | :------ | :----------------------------------------------------------- |
| brand | LANCOME | key 必须是brand卡券中心将依赖这个配置。Value配置成有意义的值一旦配置好后因为业务依赖将很难被更改掉。 |
## 用户
### 新增用户
目前除了Username是必选项其他默认也行但为了管理最好丰富下其他信息。
### 配置用户
#### Role Mappings
这里配置用户的角色,根据业务可选前面提到的**角色**。将角色添加到**Assigned Roles**.
#### Groups
给用户配置组别,前面提到有些服务需要组别来判断用户的品牌属性。
在右侧可选的组别中根据业务选择一个组,【注意】只选择一个组。
## 日志管理
在keycloak的管理-事件模块,可以管理日志。
### 开启日志
在**Config** tab可以分别开启登录和管理两类事件日志。
### 查看日志
在**登录事件**和**管理时间**两个tab可以看到两类事件的详情。

Some files were not shown because too many files have changed in this diff Show More