├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── config.yml ├── controllers ├── discount.go ├── discount_api.go ├── discount_api_test.go ├── dto.go ├── home.go ├── init_test.go └── utils.go ├── factory ├── errors.go └── factory.go ├── main.go ├── models ├── discount.go ├── discount_test.go ├── init_test.go └── utils.go ├── static └── css │ └── index.css ├── testdata └── db_fixtures │ └── discount.yml └── views ├── 404.html ├── discount ├── edit.html ├── index.html ├── new.html └── show.html ├── index.html └── layout ├── main.html ├── navbar.html └── sidebar.html /.gitignore: -------------------------------------------------------------------------------- 1 | echosample 2 | *.db 3 | tmp 4 | /.idea/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.9.x 5 | - 1.10.x 6 | - 1.11.x 7 | - master 8 | install: 9 | - go get github.com/labstack/echo 10 | - go get github.com/go-xorm/xorm 11 | - go get github.com/spf13/viper 12 | - go get github.com/asaskevich/govalidator 13 | - go get github.com/dgrijalva/jwt-go 14 | - go get github.com/sirupsen/logrus 15 | - go get github.com/pangpanglabs/goutils/... 16 | - go get github.com/go-sql-driver/mysql 17 | - go get github.com/mattn/go-sqlite3 18 | - go get github.com/opentracing/opentracing-go 19 | - go get github.com/openzipkin/zipkin-go-opentracing 20 | - go get github.com/pangpanglabs/echoswagger 21 | script: go test -coverprofile=coverage.txt -covermode=atomic github.com/pangpanglabs/echosample/... 22 | after_success: 23 | - bash <(curl -s https://codecov.io/bash) 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest 2 | 3 | RUN go get github.com/labstack/echo \ 4 | && go get github.com/go-xorm/xorm \ 5 | && go get github.com/spf13/viper \ 6 | && go get github.com/asaskevich/govalidator \ 7 | && go get github.com/dgrijalva/jwt-go \ 8 | && go get github.com/sirupsen/logrus \ 9 | && go get github.com/pangpanglabs/goutils/... \ 10 | && go get github.com/go-sql-driver/mysql \ 11 | && go get github.com/mattn/go-sqlite3 \ 12 | && go get github.com/opentracing/opentracing-go \ 13 | && go get github.com/openzipkin/zipkin-go-opentracing \ 14 | && go get github.com/pangpanglabs/echoswagger 15 | 16 | ADD . $GOPATH/src/github.com/pangpanglabs/echosample 17 | 18 | RUN go test github.com/pangpanglabs/echosample/... 19 | 20 | WORKDIR $GOPATH/src/github.com/pangpanglabs/echosample 21 | 22 | CMD go run main.go 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # echosample 2 | 3 | [](https://travis-ci.org/pangpanglabs/echosample) 4 | [](https://codecov.io/gh/pangpanglabs/echosample) 5 | 6 | 7 | ## Getting Started 8 | 9 | Get source 10 | ``` 11 | $ go get github.com/pangpanglabs/echosample 12 | ``` 13 | 14 | Test 15 | ``` 16 | $ go test github.com/pangpanglabs/echosample/... 17 | ``` 18 | 19 | Run 20 | ``` 21 | $ cd $GOPATH/src/github.com/pangpanglabs/echosample 22 | $ go run main.go 23 | ``` 24 | 25 | Visit http://127.0.0.1:8080/ 26 | 27 | ## Tips 28 | 29 | ### Live reload utility 30 | 31 | Install 32 | ``` 33 | $ go get github.com/codegangsta/gin 34 | ``` 35 | 36 | Run 37 | ``` 38 | $ gin -a 8080 -i --all r 39 | ``` 40 | 41 | Visit http://127.0.0.1:3000/ 42 | 43 | 44 | ## References 45 | 46 | - web framework: [echo framework](https://echo.labstack.com/) 47 | - orm tool: [xorm](http://xorm.io/) 48 | - logger : [logrus](https://github.com/sirupsen/logrus) 49 | - configuration tool: [viper](https://github.com/spf13/viper) 50 | - validator: [govalidator](github.com/asaskevich/govalidator) 51 | - utils: https://github.com/pangpanglabs/goutils 52 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | database: 2 | driver: sqlite3 3 | connection: echosample.db 4 | logger: 5 | # kafka: 6 | # brokers: 7 | # - steamer-01.srvs.cloudkafka.com:9093 8 | # - steamer-02.srvs.cloudkafka.com:9093 9 | # - steamer-03.srvs.cloudkafka.com:9093 10 | # topic: labs 11 | behaviorLog: 12 | kafka: 13 | # brokers: 14 | # - steamer-01.srvs.cloudkafka.com:9093 15 | # - steamer-02.srvs.cloudkafka.com:9093 16 | # - steamer-03.srvs.cloudkafka.com:9093 17 | # topic: labs 18 | debug: true 19 | service: echosample 20 | httpport: 8080 21 | -------------------------------------------------------------------------------- /controllers/discount.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/labstack/echo" 9 | "github.com/sirupsen/logrus" 10 | 11 | "github.com/pangpanglabs/echosample/factory" 12 | "github.com/pangpanglabs/echosample/models" 13 | ) 14 | 15 | type DiscountController struct { 16 | } 17 | 18 | func (c DiscountController) Init(g *echo.Group) { 19 | g.GET("", c.GetAll) 20 | g.GET("/new", c.New) 21 | g.POST("", c.Create) 22 | g.GET("/:id", c.GetOne) 23 | g.GET("/:id/edit", c.Edit) 24 | g.POST("/:id", c.Update) 25 | } 26 | 27 | func (DiscountController) GetAll(c echo.Context) error { 28 | var v SearchInput 29 | if err := c.Bind(&v); err != nil { 30 | setFlashMessage(c, map[string]string{"warning": err.Error()}) 31 | } 32 | if v.MaxResultCount == 0 { 33 | v.MaxResultCount = DefaultMaxResultCount 34 | } 35 | 36 | factory.Logger(c.Request().Context()).WithFields(logrus.Fields{ 37 | "sortby": v.Sortby, 38 | "order": v.Order, 39 | "maxResultCount": v.MaxResultCount, 40 | "skipCount": v.SkipCount, 41 | }).Info("SearchInput") 42 | 43 | totalCount, items, err := models.Discount{}.GetAll(c.Request().Context(), v.Sortby, v.Order, v.SkipCount, v.MaxResultCount) 44 | if err != nil { 45 | return err 46 | } 47 | return c.Render(http.StatusOK, "discount/index", map[string]interface{}{ 48 | "TotalCount": totalCount, 49 | "Discounts": items, 50 | "MaxResultCount": v.MaxResultCount, 51 | }) 52 | } 53 | func (DiscountController) New(c echo.Context) error { 54 | return c.Render(http.StatusOK, "discount/new", map[string]interface{}{ 55 | FlashName: getFlashMessage(c), 56 | "Form": &models.Discount{}, 57 | }) 58 | } 59 | func (DiscountController) Create(c echo.Context) error { 60 | var v DiscountInput 61 | if err := c.Bind(&v); err != nil { 62 | setFlashMessage(c, map[string]string{"error": err.Error()}) 63 | return c.Redirect(http.StatusFound, "/discount/new") 64 | } 65 | if err := c.Validate(&v); err != nil { 66 | setFlashMessage(c, map[string]string{"error": err.Error()}) 67 | return c.Redirect(http.StatusFound, "/discounts/new") 68 | } 69 | discount, err := v.ToModel() 70 | if err != nil { 71 | setFlashMessage(c, map[string]string{"error": err.Error()}) 72 | return c.Redirect(http.StatusFound, "/discounts/new") 73 | } 74 | if _, err := discount.Create(c.Request().Context()); err != nil { 75 | return err 76 | } 77 | return c.Redirect(http.StatusFound, fmt.Sprintf("/discounts/%d", discount.Id)) 78 | } 79 | func (DiscountController) GetOne(c echo.Context) error { 80 | id, err := strconv.ParseInt(c.Param("id"), 10, 64) 81 | if err != nil { 82 | return c.Render(http.StatusNotFound, "404", nil) 83 | } 84 | v, err := models.Discount{}.GetById(c.Request().Context(), id) 85 | if err != nil { 86 | return err 87 | } 88 | if v == nil { 89 | return c.Render(http.StatusNotFound, "404", nil) 90 | } 91 | return c.Render(http.StatusOK, "discount/show", map[string]interface{}{"Discount": v}) 92 | } 93 | 94 | func (DiscountController) Edit(c echo.Context) error { 95 | id, err := strconv.ParseInt(c.Param("id"), 10, 64) 96 | if err != nil { 97 | return c.Render(http.StatusNotFound, "404", nil) 98 | } 99 | v, err := models.Discount{}.GetById(c.Request().Context(), id) 100 | if err != nil { 101 | return err 102 | } 103 | if v == nil { 104 | return c.Render(http.StatusNotFound, "404", nil) 105 | } 106 | return c.Render(http.StatusOK, "discount/edit", map[string]interface{}{ 107 | FlashName: getFlashMessage(c), 108 | "Form": v, 109 | }) 110 | } 111 | func (DiscountController) Update(c echo.Context) error { 112 | var v DiscountInput 113 | if err := c.Bind(&v); err != nil { 114 | setFlashMessage(c, map[string]string{"error": err.Error()}) 115 | return c.Redirect(http.StatusFound, "/discount/new") 116 | } 117 | if err := c.Validate(&v); err != nil { 118 | setFlashMessage(c, map[string]string{"error": err.Error()}) 119 | return c.Redirect(http.StatusFound, "/discounts/new") 120 | } 121 | discount, err := v.ToModel() 122 | if err != nil { 123 | setFlashMessage(c, map[string]string{"error": err.Error()}) 124 | return c.Redirect(http.StatusFound, "/discounts/new") 125 | } 126 | 127 | id, err := strconv.ParseInt(c.Param("id"), 10, 64) 128 | if err != nil { 129 | return c.Render(http.StatusNotFound, "404", nil) 130 | } 131 | discount.Id = id 132 | if err := discount.Update(c.Request().Context()); err != nil { 133 | return err 134 | } 135 | return c.Redirect(http.StatusFound, fmt.Sprintf("/discounts/%d", discount.Id)) 136 | } 137 | -------------------------------------------------------------------------------- /controllers/discount_api.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/pangpanglabs/goutils/behaviorlog" 8 | 9 | "github.com/pangpanglabs/echosample/factory" 10 | "github.com/pangpanglabs/echosample/models" 11 | 12 | "github.com/labstack/echo" 13 | "github.com/pangpanglabs/echoswagger" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | type DiscountApiController struct { 18 | } 19 | 20 | func (c DiscountApiController) Init(g echoswagger.ApiGroup) { 21 | g.GET("", c.GetAll).AddParamQueryNested(SearchInput{}) 22 | g.POST("", c.Create).AddParamBody(DiscountInput{}, "body", "", true) 23 | g.GET("/:id", c.GetOne).AddParamPath(0, "id", "") 24 | g.PUT("/:id", c.Update).AddParamPath(0, "id", "").AddParamBody(DiscountInput{}, "body", "", true) 25 | } 26 | 27 | func (DiscountApiController) GetAll(c echo.Context) error { 28 | var v SearchInput 29 | if err := c.Bind(&v); err != nil { 30 | return renderFail(c, factory.ErrorParameter.New(err)) 31 | } 32 | if v.MaxResultCount == 0 { 33 | v.MaxResultCount = DefaultMaxResultCount 34 | } 35 | 36 | // behavior log 37 | behaviorlog.FromCtx(c.Request().Context()).WithBizAttr("maxResultCount", v.MaxResultCount).Log("SearchDiscount") 38 | 39 | // console log 40 | factory.Logger(c.Request().Context()).WithFields(logrus.Fields{ 41 | "sortby": v.Sortby, 42 | "order": v.Order, 43 | "maxResultCount": v.MaxResultCount, 44 | "skipCount": v.SkipCount, 45 | }).Info("SearchStart") 46 | 47 | totalCount, items, err := models.Discount{}.GetAll(c.Request().Context(), v.Sortby, v.Order, v.SkipCount, v.MaxResultCount) 48 | if err != nil { 49 | return renderFail(c, err) 50 | } 51 | 52 | // behavior log 53 | behaviorlog.FromCtx(c.Request().Context()). 54 | WithCallURLInfo(http.MethodGet, "https://play.google.com/books", nil, 200). 55 | WithBizAttrs(map[string]interface{}{ 56 | "totalCount": totalCount, 57 | "itemCount": len(items), 58 | }). 59 | Log("SearchComplete") 60 | 61 | return renderSucc(c, http.StatusOK, ArrayResult{ 62 | TotalCount: totalCount, 63 | Items: items, 64 | }) 65 | } 66 | 67 | func (DiscountApiController) Create(c echo.Context) error { 68 | var v DiscountInput 69 | if err := c.Bind(&v); err != nil { 70 | return renderFail(c, factory.ErrorParameter.New(err)) 71 | } 72 | if err := c.Validate(&v); err != nil { 73 | return renderFail(c, factory.ErrorParameter.New(err)) 74 | } 75 | discount, err := v.ToModel() 76 | if err != nil { 77 | return renderFail(c, factory.ErrorParameter.New(err)) 78 | } 79 | if _, err := discount.Create(c.Request().Context()); err != nil { 80 | return renderFail(c, err) 81 | } 82 | return renderSucc(c, http.StatusOK, discount) 83 | } 84 | 85 | func (DiscountApiController) GetOne(c echo.Context) error { 86 | id, err := strconv.ParseInt(c.Param("id"), 10, 64) 87 | if err != nil { 88 | return renderFail(c, factory.ErrorParameter.New(err)) 89 | } 90 | v, err := models.Discount{}.GetById(c.Request().Context(), id) 91 | if err != nil { 92 | return renderFail(c, err) 93 | } 94 | if v == nil { 95 | return renderFail(c, factory.ErrorNotFound.New(nil)) 96 | } 97 | return renderSucc(c, http.StatusOK, v) 98 | } 99 | 100 | func (DiscountApiController) Update(c echo.Context) error { 101 | var v DiscountInput 102 | if err := c.Bind(&v); err != nil { 103 | return renderFail(c, factory.ErrorParameter.New(err)) 104 | } 105 | if err := c.Validate(&v); err != nil { 106 | return renderFail(c, factory.ErrorParameter.New(err)) 107 | } 108 | discount, err := v.ToModel() 109 | if err != nil { 110 | return renderFail(c, factory.ErrorParameter.New(err)) 111 | } 112 | 113 | id, err := strconv.ParseInt(c.Param("id"), 10, 64) 114 | if err != nil { 115 | return renderFail(c, factory.ErrorParameter.New(err)) 116 | } 117 | discount.Id = id 118 | if err := discount.Update(c.Request().Context()); err != nil { 119 | return renderFail(c, err) 120 | } 121 | return renderSucc(c, http.StatusOK, v) 122 | } 123 | -------------------------------------------------------------------------------- /controllers/discount_api_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/labstack/echo" 13 | 14 | "github.com/pangpanglabs/echosample/models" 15 | "github.com/pangpanglabs/goutils/test" 16 | ) 17 | 18 | func Test_DiscountApiController_Create(t *testing.T) { 19 | req := httptest.NewRequest(echo.POST, "/api/discounts", strings.NewReader(`{"name":"discount name", "desc":"discount desc", "startAt":"2017-01-01","endAt":"2017-12-31","actionType":"Percentage","discountAmount":10,"enable":true}`)) 20 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 21 | rec := httptest.NewRecorder() 22 | test.Ok(t, handleWithFilter(DiscountApiController{}.Create, echoApp.NewContext(req, rec))) 23 | test.Equals(t, http.StatusOK, rec.Code) 24 | 25 | var v struct { 26 | Result models.Discount `json:"result"` 27 | Success bool `json:"success"` 28 | } 29 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 30 | test.Equals(t, v.Result.Name, "discount name") 31 | test.Equals(t, v.Result.StartAt.Format("2006-01-02"), "2017-01-01") 32 | } 33 | 34 | func Test_DiscountApiController_Create2(t *testing.T) { 35 | req := httptest.NewRequest(echo.POST, "/api/discounts", strings.NewReader(`{"name":"discount name#2", "desc":"discount desc#2", "startAt":"2017-02-01","endAt":"2017-11-30","actionType":"Percentage","discountAmount":20,"enable":true}`)) 36 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 37 | rec := httptest.NewRecorder() 38 | test.Ok(t, handleWithFilter(DiscountApiController{}.Create, echoApp.NewContext(req, rec))) 39 | test.Equals(t, http.StatusOK, rec.Code) 40 | 41 | var v struct { 42 | Result models.Discount `json:"result"` 43 | Success bool `json:"success"` 44 | } 45 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 46 | test.Equals(t, v.Result.Name, "discount name#2") 47 | test.Equals(t, v.Result.StartAt.Format("2006-01-02"), "2017-02-01") 48 | } 49 | 50 | func Test_DiscountApiController_Update(t *testing.T) { 51 | req := httptest.NewRequest(echo.PUT, "/api/discounts/1", strings.NewReader(`{"name":"discount name2", "desc":"discount desc2", "startAt":"2017-01-02","endAt":"2017-12-30","actionType":"Percentage","discountAmount":10,"enable":true}`)) 52 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 53 | rec := httptest.NewRecorder() 54 | c := echoApp.NewContext(req, rec) 55 | c.SetPath("/api/discounts/:id") 56 | c.SetParamNames("id") 57 | c.SetParamValues("1") 58 | test.Ok(t, handleWithFilter(DiscountApiController{}.Update, c)) 59 | test.Equals(t, http.StatusOK, rec.Code) 60 | 61 | var v struct { 62 | Result models.Discount `json:"result"` 63 | Success bool `json:"success"` 64 | } 65 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 66 | test.Equals(t, v.Result.Name, "discount name2") 67 | test.Equals(t, v.Result.StartAt.Format("2006-01-02"), "2017-01-02") 68 | test.Equals(t, v.Result.UpdatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 69 | } 70 | 71 | func Test_DiscountApiController_GetOne(t *testing.T) { 72 | // given 73 | req := httptest.NewRequest(echo.GET, "/api/discounts/1", nil) 74 | rec := httptest.NewRecorder() 75 | c := echoApp.NewContext(req, rec) 76 | c.SetPath("/api/discounts/:id") 77 | c.SetParamNames("id") 78 | c.SetParamValues("1") 79 | 80 | // when 81 | test.Ok(t, handleWithFilter(DiscountApiController{}.GetOne, c)) 82 | test.Equals(t, http.StatusOK, rec.Code) 83 | 84 | // then 85 | var v struct { 86 | Result map[string]interface{} `json:"result"` 87 | Success bool `json:"success"` 88 | } 89 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 90 | test.Equals(t, v.Result["name"], "discount name2") 91 | test.Equals(t, strings.HasPrefix(v.Result["startAt"].(string), "2017-01-02"), true) 92 | test.Equals(t, strings.HasPrefix(v.Result["endAt"].(string), "2017-02-02"), true) 93 | } 94 | 95 | func Test_DiscountApiController_GetAll_SortByAsc(t *testing.T) { 96 | req := httptest.NewRequest(echo.GET, "/api/discounts?sortby=discount_amount&order=asc", nil) 97 | rec := httptest.NewRecorder() 98 | c := echoApp.NewContext(req, rec) 99 | test.Ok(t, handleWithFilter(DiscountApiController{}.GetAll, c)) 100 | if rec.Code != http.StatusOK { 101 | fmt.Println(rec.Body.String()) 102 | } 103 | test.Equals(t, http.StatusOK, rec.Code) 104 | 105 | var v struct { 106 | Result struct { 107 | TotalCount int 108 | Items []models.Discount 109 | } `json:"result"` 110 | Success bool `json:"success"` 111 | } 112 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 113 | test.Equals(t, v.Result.TotalCount, 2) 114 | test.Equals(t, v.Result.Items[0].DiscountAmount, float64(10)) 115 | } 116 | 117 | func Test_DiscountApiController_GetAll_SortByDesc(t *testing.T) { 118 | req := httptest.NewRequest(echo.GET, "/api/discounts?sortby=discount_amount&order=desc", nil) 119 | rec := httptest.NewRecorder() 120 | c := echoApp.NewContext(req, rec) 121 | test.Ok(t, handleWithFilter(DiscountApiController{}.GetAll, c)) 122 | if rec.Code != http.StatusOK { 123 | fmt.Println(rec.Body.String()) 124 | } 125 | test.Equals(t, http.StatusOK, rec.Code) 126 | 127 | var v struct { 128 | Result struct { 129 | TotalCount int 130 | Items []models.Discount 131 | } `json:"result"` 132 | Success bool `json:"success"` 133 | } 134 | test.Ok(t, json.Unmarshal(rec.Body.Bytes(), &v)) 135 | test.Equals(t, v.Result.TotalCount, 2) 136 | test.Equals(t, v.Result.Items[0].DiscountAmount, float64(20)) 137 | } 138 | -------------------------------------------------------------------------------- /controllers/dto.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/pangpanglabs/echosample/models" 7 | ) 8 | 9 | const ( 10 | DefaultMaxResultCount = 30 11 | ) 12 | 13 | type SearchInput struct { 14 | Sortby []string `query:"sortby"` 15 | Order []string `query:"order"` 16 | SkipCount int `query:"skipCount"` 17 | MaxResultCount int `query:"maxResultCount"` 18 | } 19 | type DiscountInput struct { 20 | Name string `json:"name" valid:"required"` 21 | Desc string `json:"desc"` 22 | StartAt string `json:"startAt" valid:"required"` 23 | EndAt string `json:"endAt" valid:"required"` 24 | ActionType string `json:"actionType" valid:"required"` 25 | DiscountAmount float64 `json:"discountAmount" valid:"required"` 26 | Enable bool `json:"enable"` 27 | } 28 | 29 | func (d *DiscountInput) ToModel() (*models.Discount, error) { 30 | startAt, err := time.Parse("2006-01-02", d.StartAt) 31 | if err != nil { 32 | return nil, err 33 | } 34 | endAt, err := time.Parse("2006-01-02", d.EndAt) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &models.Discount{ 39 | Name: d.Name, 40 | Desc: d.Desc, 41 | StartAt: startAt, 42 | EndAt: endAt, 43 | ActionType: d.ActionType, 44 | DiscountAmount: d.DiscountAmount, 45 | Enable: d.Enable, 46 | }, nil 47 | } 48 | -------------------------------------------------------------------------------- /controllers/home.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo" 7 | ) 8 | 9 | type HomeController struct { 10 | } 11 | 12 | func (c HomeController) Init(g *echo.Group) { 13 | g.GET("", c.Get) 14 | } 15 | func (HomeController) Get(c echo.Context) error { 16 | return c.Render(http.StatusOK, "index", nil) 17 | } 18 | -------------------------------------------------------------------------------- /controllers/init_test.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "gopkg.in/testfixtures.v2" 5 | "runtime" 6 | 7 | "github.com/asaskevich/govalidator" 8 | "github.com/go-xorm/xorm" 9 | "github.com/labstack/echo" 10 | _ "github.com/mattn/go-sqlite3" 11 | 12 | "github.com/pangpanglabs/echosample/models" 13 | "github.com/pangpanglabs/goutils/echomiddleware" 14 | ) 15 | 16 | var ( 17 | echoApp *echo.Echo 18 | handleWithFilter func(handlerFunc echo.HandlerFunc, c echo.Context) error 19 | ) 20 | 21 | func init() { 22 | runtime.GOMAXPROCS(1) 23 | xormEngine, err := xorm.NewEngine("sqlite3", ":memory:") 24 | if err != nil { 25 | panic(err) 26 | } 27 | xormEngine.Sync(new(models.Discount)) 28 | 29 | fixtures, err := testfixtures.NewFolder(xormEngine.DB().DB, &testfixtures.SQLite{}, "../testdata/db_fixtures") 30 | if err != nil { 31 | panic(err) 32 | } 33 | testfixtures.SkipDatabaseNameCheck(true) 34 | 35 | if err := fixtures.Load(); err != nil { 36 | panic(err) 37 | } 38 | 39 | echoApp = echo.New() 40 | echoApp.Validator = &Validator{} 41 | 42 | logger := echomiddleware.ContextLogger() 43 | db := echomiddleware.ContextDB("test", xormEngine, echomiddleware.KafkaConfig{}) 44 | 45 | handleWithFilter = func(handlerFunc echo.HandlerFunc, c echo.Context) error { 46 | return logger(db(handlerFunc))(c) 47 | } 48 | } 49 | 50 | type Validator struct{} 51 | 52 | func (v *Validator) Validate(i interface{}) error { 53 | _, err := govalidator.ValidateStruct(i) 54 | return err 55 | } 56 | -------------------------------------------------------------------------------- /controllers/utils.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/go-xorm/xorm" 10 | "github.com/labstack/echo" 11 | "github.com/pangpanglabs/echosample/factory" 12 | "github.com/pangpanglabs/goutils/behaviorlog" 13 | ) 14 | 15 | const ( 16 | FlashName = "flash" 17 | FlashSeparator = ";" 18 | ) 19 | 20 | type ApiResult struct { 21 | Result interface{} `json:"result"` 22 | Success bool `json:"success"` 23 | Error factory.Error `json:"error"` 24 | } 25 | 26 | type ArrayResult struct { 27 | Items interface{} `json:"items"` 28 | TotalCount int64 `json:"totalCount"` 29 | } 30 | 31 | func renderFail(c echo.Context, err error) error { 32 | if err == nil { 33 | err = factory.ErrorSystem.New(nil) 34 | } 35 | behaviorlog.FromCtx(c.Request().Context()).WithError(err) 36 | var apiError *factory.Error 37 | if ok := errors.As(err, &apiError); ok { 38 | return c.JSON(apiError.Status(), ApiResult{ 39 | Success: false, 40 | Error: *apiError, 41 | }) 42 | } 43 | return err 44 | } 45 | 46 | func renderSucc(c echo.Context, status int, result interface{}) error { 47 | req := c.Request() 48 | if req.Method == "POST" || req.Method == "PUT" || req.Method == "DELETE" { 49 | if session, ok := factory.DB(req.Context()).(*xorm.Session); ok { 50 | err := session.Commit() 51 | if err != nil { 52 | return renderFail(c, factory.ErrorDB.New(err)) 53 | } 54 | } 55 | } 56 | 57 | return c.JSON(status, ApiResult{ 58 | Success: true, 59 | Result: result, 60 | }) 61 | } 62 | 63 | func setFlashMessage(c echo.Context, m map[string]string) { 64 | var flashValue string 65 | for key, value := range m { 66 | flashValue += "\x00" + key + "\x23" + FlashSeparator + "\x23" + value + "\x00" 67 | } 68 | 69 | c.SetCookie(&http.Cookie{ 70 | Name: FlashName, 71 | Value: url.QueryEscape(flashValue), 72 | }) 73 | } 74 | func getFlashMessage(c echo.Context) map[string]string { 75 | cookie, err := c.Cookie(FlashName) 76 | if err != nil { 77 | return nil 78 | } 79 | 80 | m := map[string]string{} 81 | 82 | v, _ := url.QueryUnescape(cookie.Value) 83 | vals := strings.Split(v, "\x00") 84 | for _, v := range vals { 85 | if len(v) > 0 { 86 | kv := strings.Split(v, "\x23"+FlashSeparator+"\x23") 87 | if len(kv) == 2 { 88 | m[kv[0]] = kv[1] 89 | } 90 | } 91 | } 92 | //read one time then delete it 93 | c.SetCookie(&http.Cookie{ 94 | Name: FlashName, 95 | Value: "", 96 | MaxAge: -1, 97 | Path: "/", 98 | }) 99 | 100 | return m 101 | } 102 | -------------------------------------------------------------------------------- /factory/errors.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | const WrapErrorMessage = "echosample error" 9 | 10 | type Error struct { 11 | Code int `json:"code,omitempty"` 12 | Message string `json:"message,omitempty"` 13 | Details string `json:"details,omitempty"` 14 | err error 15 | status int 16 | } 17 | 18 | func (t ErrorTemplate) New(err error, v ...interface{}) *Error { 19 | e := Error{ 20 | Code: t.Code, 21 | Message: fmt.Sprintf(t.Message, v...), 22 | err: err, 23 | } 24 | if err != nil { 25 | e.Details = fmt.Sprintf("%s: %s", WrapErrorMessage, err.Error()) 26 | } 27 | return &e 28 | } 29 | 30 | func (e *Error) Error() string { 31 | if e == nil { 32 | return "" 33 | } 34 | return e.Details 35 | } 36 | 37 | func (e *Error) Unwrap() error { 38 | if e == nil { 39 | return nil 40 | } 41 | return e.err 42 | } 43 | 44 | func (e *Error) Status() int { 45 | if e == nil || e.status == 0 { 46 | return http.StatusInternalServerError 47 | } 48 | return e.status 49 | } 50 | 51 | type ErrorTemplate Error 52 | 53 | var ( 54 | // System Error 55 | ErrorSystem = ErrorTemplate{Code: 10001, Message: "System Error"} 56 | ErrorServiceUnavailable = ErrorTemplate{Code: 10002, Message: "Service unavailable"} 57 | ErrorRemoteService = ErrorTemplate{Code: 10003, Message: "Remote service error"} 58 | ErrorIPLimit = ErrorTemplate{Code: 10004, Message: "IP limit"} 59 | ErrorPermissionDenied = ErrorTemplate{Code: 10005, Message: "Permission denied", status: http.StatusForbidden} 60 | ErrorIllegalRequest = ErrorTemplate{Code: 10006, Message: "Illegal request", status: http.StatusBadRequest} 61 | ErrorHTTPMethod = ErrorTemplate{Code: 10007, Message: "HTTP method is not suported for this request", status: http.StatusMethodNotAllowed} 62 | ErrorParameter = ErrorTemplate{Code: 10008, Message: "Parameter error", status: http.StatusBadRequest} 63 | ErrorMissParameter = ErrorTemplate{Code: 10009, Message: "Miss required parameter", status: http.StatusBadRequest} 64 | ErrorDB = ErrorTemplate{Code: 10010, Message: "DB error, please contact the administator"} 65 | ErrorTokenInvaild = ErrorTemplate{Code: 10011, Message: "Token invaild", status: http.StatusUnauthorized} 66 | ErrorMissToken = ErrorTemplate{Code: 10012, Message: "Miss token", status: http.StatusUnauthorized} 67 | ErrorVersion = ErrorTemplate{Code: 10013, Message: "API version %s invalid"} 68 | ErrorNotFound = ErrorTemplate{Code: 10014, Message: "Resource not found", status: http.StatusNotFound} 69 | // Business Error 70 | ErrorDiscountNotExists = ErrorTemplate{Code: 20001, Message: "Discount %d does not exists"} 71 | ) 72 | -------------------------------------------------------------------------------- /factory/factory.go: -------------------------------------------------------------------------------- 1 | package factory 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/go-xorm/xorm" 7 | "github.com/pangpanglabs/goutils/echomiddleware" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func DB(ctx context.Context) xorm.Interface { 12 | v := ctx.Value(echomiddleware.ContextDBName) 13 | if v == nil { 14 | panic("DB is not exist") 15 | } 16 | if db, ok := v.(*xorm.Session); ok { 17 | return db 18 | } 19 | if db, ok := v.(*xorm.Engine); ok { 20 | return db 21 | } 22 | panic("DB is not exist") 23 | } 24 | 25 | func Logger(ctx context.Context) *logrus.Entry { 26 | v := ctx.Value(echomiddleware.ContextLoggerName) 27 | if v == nil { 28 | return logrus.WithFields(logrus.Fields{}) 29 | } 30 | if logger, ok := v.(*logrus.Entry); ok { 31 | return logger 32 | } 33 | return logrus.WithFields(logrus.Fields{}) 34 | } 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "runtime" 9 | 10 | "github.com/asaskevich/govalidator" 11 | _ "github.com/go-sql-driver/mysql" 12 | "github.com/go-xorm/xorm" 13 | "github.com/labstack/echo" 14 | "github.com/labstack/echo/middleware" 15 | _ "github.com/mattn/go-sqlite3" 16 | "github.com/pangpanglabs/echoswagger" 17 | configutil "github.com/pangpanglabs/goutils/config" 18 | "github.com/pangpanglabs/goutils/echomiddleware" 19 | "github.com/pangpanglabs/goutils/echotpl" 20 | 21 | "github.com/pangpanglabs/echosample/controllers" 22 | "github.com/pangpanglabs/echosample/models" 23 | ) 24 | 25 | func main() { 26 | appEnv := flag.String("app-env", os.Getenv("APP_ENV"), "app env") 27 | flag.Parse() 28 | 29 | var c Config 30 | if err := configutil.Read(*appEnv, &c); err != nil { 31 | panic(err) 32 | } 33 | fmt.Println(c) 34 | db, err := initDB(c.Database.Driver, c.Database.Connection) 35 | if err != nil { 36 | panic(err) 37 | } 38 | defer db.Close() 39 | 40 | e := echo.New() 41 | 42 | r := echoswagger.New(e, "/doc", &echoswagger.Info{ 43 | Title: "Echo Sample", 44 | Description: "This is API doc for Echo Sample", 45 | Version: "1.0", 46 | }) 47 | 48 | controllers.HomeController{}.Init(e.Group("/")) 49 | controllers.DiscountController{}.Init(e.Group("/discounts")) 50 | controllers.DiscountApiController{}.Init(r.Group("Discount", "/api/discounts")) 51 | 52 | e.Static("/static", "static") 53 | e.Pre(middleware.RemoveTrailingSlash()) 54 | e.Pre(echomiddleware.ContextBase()) 55 | e.Use(middleware.Recover()) 56 | e.Use(middleware.CORS()) 57 | // e.Use(middleware.Logger()) 58 | e.Use(echomiddleware.ContextLogger()) 59 | e.Use(echomiddleware.ContextDB(c.Service, db, echomiddleware.KafkaConfig(c.Database.Logger.Kafka))) 60 | e.Use(echomiddleware.BehaviorLogger(c.Service, echomiddleware.KafkaConfig(c.BehaviorLog.Kafka))) 61 | 62 | e.Renderer = echotpl.New() 63 | e.Validator = &Validator{} 64 | e.Debug = c.Debug 65 | 66 | if err := e.Start(":" + c.HttpPort); err != nil { 67 | log.Println(err) 68 | } 69 | 70 | } 71 | 72 | func initDB(driver, connection string) (*xorm.Engine, error) { 73 | db, err := xorm.NewEngine(driver, connection) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | if driver == "sqlite3" { 79 | runtime.GOMAXPROCS(1) 80 | } 81 | 82 | db.Sync(new(models.Discount)) 83 | return db, nil 84 | } 85 | 86 | type Config struct { 87 | Database struct { 88 | Driver string 89 | Connection string 90 | Logger struct { 91 | Kafka echomiddleware.KafkaConfig 92 | } 93 | } 94 | BehaviorLog struct { 95 | Kafka echomiddleware.KafkaConfig 96 | } 97 | Trace struct { 98 | Zipkin echomiddleware.ZipkinConfig 99 | } 100 | 101 | Debug bool 102 | Service string 103 | HttpPort string 104 | } 105 | 106 | type Validator struct{} 107 | 108 | func (v *Validator) Validate(i interface{}) error { 109 | _, err := govalidator.ValidateStruct(i) 110 | return err 111 | } 112 | -------------------------------------------------------------------------------- /models/discount.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/pangpanglabs/echosample/factory" 8 | ) 9 | 10 | type Discount struct { 11 | Id int64 `json:"id"` 12 | Name string `json:"name"` 13 | Desc string `json:"desc"` 14 | StartAt time.Time `json:"startAt"` 15 | EndAt time.Time `json:"endAt"` 16 | ActionType string `json:"actionType"` 17 | DiscountAmount float64 `json:"discountAmount"` 18 | Enable bool `json:"enable"` 19 | CreatedAt time.Time `json:"createdAt" xorm:"created"` 20 | UpdatedAt time.Time `json:"updatedAt" xorm:"updated"` 21 | } 22 | 23 | func (d *Discount) Create(ctx context.Context) (int64, error) { 24 | affected, err := factory.DB(ctx).Insert(d) 25 | if err != nil { 26 | return 0, factory.ErrorDB.New(err) 27 | } 28 | return affected, nil 29 | } 30 | 31 | func (Discount) GetById(ctx context.Context, id int64) (*Discount, error) { 32 | var v Discount 33 | if has, err := factory.DB(ctx).ID(id).Get(&v); err != nil { 34 | return nil, factory.ErrorDB.New(err) 35 | } else if !has { 36 | return nil, nil 37 | } 38 | return &v, nil 39 | } 40 | 41 | func (Discount) GetAll(ctx context.Context, sortby, order []string, offset, limit int) (int64, []Discount, error) { 42 | q := factory.DB(ctx) 43 | if err := setSortOrder(q, sortby, order); err != nil { 44 | factory.Logger(ctx).Error(err) 45 | } 46 | 47 | var items []Discount 48 | totalCount, err := q.Limit(limit, offset).FindAndCount(&items) 49 | if err != nil { 50 | return 0, nil, factory.ErrorDB.New(err) 51 | } 52 | return totalCount, items, nil 53 | } 54 | 55 | func (d *Discount) Update(ctx context.Context) error { 56 | if origin, err := d.GetById(ctx, d.Id); err != nil { 57 | return factory.ErrorDB.New(err) 58 | } else if origin == nil { 59 | return factory.ErrorDiscountNotExists.New(err, d.Id) 60 | } 61 | 62 | if _, err := factory.DB(ctx).ID(d.Id).Update(d); err != nil { 63 | return factory.ErrorDB.New(err) 64 | } 65 | return nil 66 | } 67 | 68 | func (Discount) Delete(ctx context.Context, id int64) error { 69 | if origin, err := (Discount{}).GetById(ctx, id); err != nil { 70 | return factory.ErrorDB.New(err) 71 | } else if origin == nil { 72 | return factory.ErrorDiscountNotExists.New(err, id) 73 | } 74 | 75 | if _, err := factory.DB(ctx).ID(id).Delete(&Discount{}); err != nil { 76 | return factory.ErrorDB.New(err) 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /models/discount_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/pangpanglabs/goutils/test" 9 | ) 10 | 11 | func TestDiscountCreate(t *testing.T) { 12 | d1 := Discount{ 13 | Name: "name1", 14 | Desc: "desc1", 15 | } 16 | affected, err := d1.Create(ctx) 17 | test.Ok(t, err) 18 | test.Equals(t, affected, int64(1)) 19 | test.Equals(t, d1.Id, int64(1)) 20 | test.Equals(t, d1.CreatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 21 | test.Equals(t, d1.UpdatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 22 | 23 | d2 := Discount{ 24 | Name: "name2", 25 | Desc: "desc2", 26 | } 27 | affected, err = d2.Create(ctx) 28 | test.Ok(t, err) 29 | test.Equals(t, affected, int64(1)) 30 | test.Equals(t, d2.Id, int64(2)) 31 | test.Equals(t, d1.CreatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 32 | test.Equals(t, d1.UpdatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 33 | } 34 | 35 | func TestDiscountGetAndUpdate(t *testing.T) { 36 | d, err := Discount{}.GetById(ctx, 1) 37 | test.Ok(t, err) 38 | test.Equals(t, d.Id, int64(1)) 39 | test.Equals(t, d.Name, "name1") 40 | test.Equals(t, d.CreatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 41 | test.Equals(t, d.UpdatedAt.Format("2006-01-02"), time.Now().Format("2006-01-02")) 42 | 43 | d.Name = "name1-2" 44 | err = d.Update(ctx) 45 | test.Ok(t, err) 46 | test.Equals(t, d.Name, "name1-2") 47 | 48 | } 49 | 50 | func TestDiscountGetAll(t *testing.T) { 51 | totalCount, items, err := Discount{}.GetAll(ctx, []string{"name"}, []string{"desc"}, 0, 10) 52 | test.Ok(t, err) 53 | test.Equals(t, totalCount, int64(2)) 54 | test.Equals(t, items[0].Id, int64(2)) 55 | test.Equals(t, items[1].Id, int64(1)) 56 | } 57 | 58 | func TestXXX(t *testing.T) { 59 | at, err := time.Parse("2006-01-02", "2017-12-31") 60 | test.Ok(t, err) 61 | test.Equals(t, at.Year(), 2017) 62 | test.Assert(t, at.Month() == 12, "Month should be equals to 12") 63 | fmt.Println(at) 64 | } 65 | -------------------------------------------------------------------------------- /models/init_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | 7 | "github.com/go-xorm/xorm" 8 | _ "github.com/mattn/go-sqlite3" 9 | "github.com/pangpanglabs/goutils/echomiddleware" 10 | ) 11 | 12 | var ctx context.Context 13 | 14 | func init() { 15 | runtime.GOMAXPROCS(1) 16 | xormEngine, err := xorm.NewEngine("sqlite3", ":memory:") 17 | if err != nil { 18 | panic(err) 19 | } 20 | xormEngine.ShowSQL(true) 21 | xormEngine.Sync(new(Discount)) 22 | ctx = context.WithValue(context.Background(), echomiddleware.ContextDBName, xormEngine.NewSession()) 23 | } 24 | -------------------------------------------------------------------------------- /models/utils.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/go-xorm/xorm" 7 | ) 8 | 9 | func setSortOrder(q xorm.Interface, sortby, order []string) error { 10 | if len(sortby) != 0 { 11 | if len(sortby) == len(order) { 12 | // 1) for each sort field, there is an associated order 13 | for i, v := range sortby { 14 | if order[i] == "desc" { 15 | q.Desc(v) 16 | } else if order[i] == "asc" { 17 | q.Asc(v) 18 | } else { 19 | return errors.New("Invalid order. Must be either [asc|desc]") 20 | } 21 | } 22 | } else if len(sortby) != len(order) && len(order) == 1 { 23 | // 2) there is exactly one order, all the sorted fields will be sorted by this order 24 | for _, v := range sortby { 25 | if order[0] == "desc" { 26 | q.Desc(v) 27 | } else if order[0] == "asc" { 28 | q.Asc(v) 29 | } else { 30 | return errors.New("Invalid order. Must be either [asc|desc]") 31 | } 32 | } 33 | } else if len(sortby) != len(order) && len(order) != 1 { 34 | return errors.New("'sortby', 'order' sizes mismatch or 'order' size is not 1") 35 | } 36 | } else { 37 | if len(order) != 0 { 38 | return errors.New("unused 'order' fields") 39 | } 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /static/css/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Base structure 3 | */ 4 | 5 | /* Move down content because we have a fixed navbar that is 50px tall */ 6 | body { 7 | padding-top: 50px; 8 | } 9 | 10 | 11 | /* 12 | * Global add-ons 13 | */ 14 | 15 | .sub-header { 16 | padding-bottom: 10px; 17 | border-bottom: 1px solid #eee; 18 | } 19 | 20 | /* 21 | * Top navigation 22 | * Hide default border to remove 1px line. 23 | */ 24 | .navbar-fixed-top { 25 | border: 0; 26 | } 27 | 28 | /* 29 | * Sidebar 30 | */ 31 | 32 | /* Hide for mobile, show later */ 33 | .sidebar { 34 | display: none; 35 | } 36 | @media (min-width: 768px) { 37 | .sidebar { 38 | position: fixed; 39 | top: 51px; 40 | bottom: 0; 41 | left: 0; 42 | z-index: 1000; 43 | display: block; 44 | padding: 20px; 45 | overflow-x: hidden; 46 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 47 | background-color: #f5f5f5; 48 | border-right: 1px solid #eee; 49 | } 50 | } 51 | 52 | /* Sidebar navigation */ 53 | .nav-sidebar { 54 | margin-right: -21px; /* 20px padding + 1px border */ 55 | margin-bottom: 20px; 56 | margin-left: -20px; 57 | } 58 | .nav-sidebar > li > a { 59 | padding-right: 20px; 60 | padding-left: 20px; 61 | } 62 | .nav-sidebar > .active > a, 63 | .nav-sidebar > .active > a:hover, 64 | .nav-sidebar > .active > a:focus { 65 | color: #fff; 66 | background-color: #428bca; 67 | } 68 | 69 | 70 | /* 71 | * Main content 72 | */ 73 | 74 | .main { 75 | padding: 20px; 76 | } 77 | @media (min-width: 768px) { 78 | .main { 79 | padding-right: 40px; 80 | padding-left: 40px; 81 | } 82 | } 83 | .main .page-header { 84 | margin-top: 0; 85 | } 86 | 87 | 88 | /* 89 | * Placeholder dashboard ideas 90 | */ 91 | 92 | .placeholders { 93 | margin-bottom: 30px; 94 | text-align: center; 95 | } 96 | .placeholders h4 { 97 | margin-bottom: 0; 98 | } 99 | .placeholder { 100 | margin-bottom: 20px; 101 | } 102 | .placeholder img { 103 | display: inline-block; 104 | border-radius: 50%; 105 | } -------------------------------------------------------------------------------- /testdata/db_fixtures/discount.yml: -------------------------------------------------------------------------------- 1 | - id: 1 2 | name: discount name2 3 | start_at: RAW=datetime('2017-01-02', 'localtime') 4 | end_at: RAW=datetime('2017-02-02', 'localtime') 5 | created_at: RAW=datetime('now') 6 | updated_at: RAW=datetime('now') -------------------------------------------------------------------------------- /views/404.html: -------------------------------------------------------------------------------- 1 | {{define "404"}} 2 | 3 | 4 |
5 |4 | New Discount 5 |
6 |Id | 10 |Name | 11 |StartAt | 12 |EndAt | 13 |ActionType | 14 |DiscountAmount | 15 |Enable | 16 |Action | 17 |
---|---|---|---|---|---|---|---|
{{.Id}} | 23 |{{.Name}} | 24 |{{.StartAt.Format "2006-01-02"}} | 25 |{{.EndAt.Format "2006-01-02"}} | 26 |{{.ActionType}} | 27 |{{.DiscountAmount}} | 28 |{{.Enable}} | 29 |30 | [Show] 31 | [Edit] 32 | | 33 |
56 | Back 57 |
58 | {{with .flash.error}}