├── .bowerrc ├── .editorconfig ├── .env.example ├── .github ├── FUNDING.yml └── workflows │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .scrutinizer.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── _ide_helper.php ├── codeception.yml ├── commands ├── CrontabController.php ├── FixController.php ├── GenerateController.php ├── HelloController.php └── InitController.php ├── composer.json ├── composer.lock ├── config ├── common.php ├── console.php ├── params.php ├── test.php └── web.php ├── controllers └── SiteController.php ├── core ├── EventBootstrap.php ├── actions │ └── CreateAction.php ├── behaviors │ └── LoggerBehavior.php ├── components │ ├── ErrorHandler.php │ └── ResponseHandler.php ├── exceptions │ ├── CannotOperateException.php │ ├── ErrorCodes.php │ ├── FileException.php │ ├── InternalException.php │ ├── InvalidArgumentException.php │ └── ThirdPartyServiceErrorException.php ├── helpers │ ├── ArrayHelper.php │ ├── HolidayHelper.php │ └── SearchHelper.php ├── messages │ └── zh-CN │ │ ├── app.php │ │ └── exception.php ├── models │ ├── Account.php │ ├── AuthClient.php │ ├── Category.php │ ├── CurrencyRate.php │ ├── Record.php │ ├── Recurrence.php │ ├── Rule.php │ ├── Tag.php │ ├── Transaction.php │ └── User.php ├── requests │ ├── JoinRequest.php │ ├── LoginRequest.php │ ├── RecurrenceUpdateStatusRequest.php │ ├── RuleUpdateStatusRequest.php │ ├── TransactionCreateByDescRequest.php │ └── TransactionUploadRequest.php ├── services │ ├── AccountService.php │ ├── AnalysisService.php │ ├── CategoryService.php │ ├── RecurrenceService.php │ ├── RuleService.php │ ├── TagService.php │ ├── TelegramService.php │ ├── TransactionService.php │ ├── UploadService.php │ └── UserService.php ├── traits │ ├── FixDataTrait.php │ ├── SendRequestTrait.php │ └── ServiceTrait.php └── types │ ├── AccountStatus.php │ ├── AccountType.php │ ├── AnalysisDateType.php │ ├── AnalysisGroupDateType.php │ ├── AuthClientStatus.php │ ├── AuthClientType.php │ ├── BaseStatus.php │ ├── BaseType.php │ ├── ColorType.php │ ├── CurrencyCode.php │ ├── DirectionType.php │ ├── RecordSource.php │ ├── RecurrenceFrequency.php │ ├── RecurrenceStatus.php │ ├── ReimbursementStatus.php │ ├── RuleStatus.php │ ├── TelegramAction.php │ ├── TelegramKeyword.php │ ├── TransactionRating.php │ ├── TransactionStatus.php │ ├── TransactionType.php │ └── UserStatus.php ├── docker-compose.yml ├── grumphp.yml ├── image-files ├── etc │ └── apache2 │ │ ├── apache2.conf │ │ └── sites-available │ │ ├── 000-default.conf │ │ └── default-ssl.config ├── root │ └── .bashrc └── usr │ └── local │ ├── bin │ └── docker-entrypoint.sh │ └── etc │ └── php │ └── conf.d │ ├── base.ini │ └── xdebug.ini ├── mail └── layouts │ └── html.php ├── migrations ├── m200717_082932_create_user_table.php ├── m200730_062450_create_account_table.php ├── m200730_065801_create_category_table.php ├── m200730_082622_create_currency_rate_table.php ├── m200730_085233_create_tag_table.php ├── m200812_090059_add_default_column.php ├── m200814_032606_create_rule_table.php ├── m200818_021303_update_rules_table.php ├── m200818_130329_create_transaction_table.php ├── m200818_131101_create_record_table.php ├── m200821_031033_update_category_table.php ├── m200821_031118_update_account_table.php ├── m200823_094104_create_auth_client_table.php ├── m200828_095405_update_record_table.php ├── m200830_030728_create_sort_column.php ├── m200909_030624_create_recurrence_table.php └── m200917_064807_create_exclude_from_stats_column.php ├── modules └── v1 │ ├── Module.php │ └── controllers │ ├── AccountController.php │ ├── ActiveController.php │ ├── AnalysisController.php │ ├── CategoryController.php │ ├── RecordController.php │ ├── RecurrenceController.php │ ├── RuleController.php │ ├── TagController.php │ ├── TelegramController.php │ ├── TransactionController.php │ └── UserController.php ├── requirements.php ├── runtime └── .gitignore ├── tests ├── _bootstrap.php ├── _data │ └── .gitkeep ├── _output │ └── .gitignore ├── _support │ ├── ApiTester.php │ └── Helper │ │ └── Api.php ├── api.suite.yml └── api │ ├── CreateUserCest.php │ └── LoginUserCest.php ├── web ├── .htaccess ├── assets │ └── .gitignore ├── index.php └── uploads │ ├── .gitignore │ └── import-template.csv ├── yii └── yii.bat /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory" : "vendor/bower-asset" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Framework 2 | # --------- 3 | YII_DEBUG=true 4 | YII_ENV=dev 5 | APP_URL=http://localhost:8080 6 | APP_NAME=Yii-REST-API 7 | APP_TIME_ZONE=Asia/Shanghai 8 | APP_LANGUAGE=zh-CN 9 | COOKIE_VALIDATION_KEY= 10 | 11 | # Databases 12 | # --------- 13 | MYSQL_HOST=127.0.0.1 14 | MYSQL_PORT=3306 15 | MYSQL_DATABASE=cashwarden 16 | MYSQL_USERNAME=root 17 | MYSQL_PASSWORD=root 18 | 19 | ADMIN_EMAIL=admin@gmail.com 20 | SENDER_EMAIL=no-reply@gmail.com 21 | SENDER_NAME="Yii-rest-api bot" 22 | 23 | # JWT 24 | JWT_SECRET= 25 | 26 | # graylog 27 | GRAYLOG_HOST= 28 | GRAYLOG_TAG=local 29 | 30 | # telegram 31 | TELEGRAM_TOKEN= 32 | TELEGRAM_BOT_NAME=CashwardenBot 33 | 34 | # rest token expore 35 | USER_RESET_TOKEN_EXPIRE= 36 | 37 | SEO_KEYWORDS= 38 | SEO_DESCRIPTION= 39 | 40 | GOOGLE_ANALYTICS_AU= 41 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [forecho] 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [ push, pull_request ] 3 | 4 | jobs: 5 | grumphp: 6 | name: Run GrumPHP 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup PHP 13 | uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: 7.4 16 | 17 | - name: Get composer cache directory 18 | id: composercache 19 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 20 | 21 | - name: Cache composer dependencies 22 | uses: actions/cache@v2 23 | with: 24 | path: ${{ steps.composercache.outputs.dir }} 25 | # Use composer.json for key, if composer.lock is not committed. 26 | # key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 27 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 28 | restore-keys: ${{ runner.os }}-composer- 29 | 30 | - name: Install Composer dependencies 31 | run: | 32 | composer install --no-progress --prefer-dist 33 | 34 | - name: Run GrumPHP 35 | run: ./vendor/bin/grumphp run 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # GitHub Action for Yii Framework with MySQL 2 | name: Testing 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | jobs: 9 | yii: 10 | name: Yii2 (PHP ${{ matrix.php-versions }}) 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | php-versions: [ '7.4' ] 16 | # php-versions: ['7.2', '7.3', '7.4'] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup PHP, with composer and extensions 22 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php 23 | with: 24 | php-version: ${{ matrix.php-versions }} 25 | extensions: mbstring, intl, gd, imagick, zip, dom, mysql 26 | coverage: xdebug #optional 27 | 28 | - name: Set up MySQL 29 | uses: mirromutth/mysql-action@v1.1 30 | with: 31 | collation server: utf8mb4_unicode_ci 32 | mysql version: 5.7 33 | mysql database: cashwarden 34 | mysql root password: root 35 | 36 | # https://github.com/mirromutth/mysql-action/issues/10 37 | - name: Wait for MySQL 38 | run: | 39 | while ! mysqladmin ping --host=127.0.0.1 --password=root --silent; do 40 | sleep 1 41 | done 42 | 43 | - name: Get composer cache directory 44 | id: composercache 45 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 46 | 47 | - name: Cache composer dependencies 48 | uses: actions/cache@v2 49 | with: 50 | path: ${{ steps.composercache.outputs.dir }} 51 | # Use composer.json for key, if composer.lock is not committed. 52 | # key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 53 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 54 | restore-keys: ${{ runner.os }}-composer- 55 | 56 | - name: Install Composer dependencies 57 | run: | 58 | composer install --no-progress --prefer-dist --no-interaction 59 | 60 | - name: Prepare the application 61 | run: | 62 | php -r "file_exists('.env') || copy('.env.example', '.env');" 63 | php yii generate/key 64 | 65 | - name: Run Tests 66 | run: | 67 | vendor/bin/codecept build 68 | php yii migrate --interactive=0 69 | nohup php -S localhost:8080 > yii.log 2>&1 & 70 | vendor/bin/codecept run --coverage --coverage-xml=coverage.clover 71 | wget https://scrutinizer-ci.com/ocular.phar 72 | php ocular.phar code-coverage:upload --format=php-clover tests/_output/coverage.clover 73 | # bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_TOKEN }} || echo 'Codecov did not collect coverage reports' 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | # composer vendor dir 16 | /vendor 17 | 18 | # composer itself is not needed 19 | composer.phar 20 | 21 | # Mac DS_Store Files 22 | .DS_Store 23 | 24 | # phpunit itself is not needed 25 | phpunit.phar 26 | # local phpunit config 27 | /phpunit.xml 28 | 29 | tests/_output/* 30 | tests/_support/_generated 31 | 32 | #vagrant folder 33 | /.vagrant 34 | 35 | # env 36 | .env 37 | .env.testing 38 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | nodes: 3 | analysis: 4 | tests: 5 | override: 6 | - php-scrutinizer-run 7 | checks: 8 | php: 9 | code_rating: true 10 | remove_extra_empty_lines: true 11 | remove_php_closing_tag: true 12 | remove_trailing_whitespace: true 13 | fix_use_statements: 14 | remove_unused: true 15 | preserve_multiple: false 16 | preserve_blanklines: true 17 | order_alphabetically: true 18 | fix_php_opening_tag: true 19 | fix_linefeed: true 20 | fix_line_ending: true 21 | fix_identation_4spaces: true 22 | fix_doc_comments: true 23 | tools: 24 | external_code_coverage: 25 | timeout: 600 26 | runs: 3 27 | php_analyzer: true 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4.3-apache 2 | 3 | ARG TIMEZONE 4 | 5 | RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list \ 6 | && sed -i 's|security.debian.org/debian-security|mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list \ 7 | && requirements="vim cron mariadb-client libwebp-dev libxpm-dev libmcrypt-dev libmcrypt4 libcurl3-dev libxml2-dev \ 8 | libmemcached-dev zlib1g-dev libc6 libstdc++6 libkrb5-3 openssl debconf libfreetype6-dev libjpeg-dev libtiff-dev \ 9 | libpng-dev git libmagickwand-dev ghostscript gsfonts libbz2-dev libonig-dev libzip-dev zip unzip" \ 10 | && apt-get update && apt-get install -y --no-install-recommends $requirements && apt-get clean && rm -rf /var/lib/apt/lists/* \ 11 | && docker-php-ext-install mysqli \ 12 | pdo_mysql \ 13 | gd \ 14 | exif \ 15 | bcmath \ 16 | opcache \ 17 | && docker-php-ext-enable opcache 18 | 19 | RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone \ 20 | && printf '[PHP]\ndate.timezone = "%s"\n', ${TIMEZONE} > /usr/local/etc/php/conf.d/tzone.ini \ 21 | && "date" 22 | 23 | RUN a2enmod headers && \ 24 | a2enmod rewrite 25 | 26 | # Install composer 27 | COPY --from=composer /usr/bin/composer /usr/bin/composer 28 | 29 | # Add configuration files 30 | COPY image-files/ / 31 | 32 | RUN chmod 700 \ 33 | /usr/local/bin/docker-entrypoint.sh 34 | 35 | WORKDIR /srv 36 | COPY . /srv/ 37 | 38 | RUN composer install --prefer-dist \ 39 | && chmod 777 -R /srv/runtime \ 40 | && chmod +x /srv/yii \ 41 | && chmod 777 -R /srv/web/assets \ 42 | && chmod 777 -R /srv/web/uploads \ 43 | && chown -R www-data:www-data /srv/ 44 | 45 | EXPOSE 80 46 | 47 | ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] 48 | 49 | CMD ["apache2-foreground"] 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Yii 2 REST API Project Template

6 |
7 |

8 | 9 | Yii 2 REST API Project Template is a skeleton [Yii 2](http://www.yiiframework.com/) application best for 10 | rapidly creating small rest api projects. 11 | 12 | The template contains the basic features including user join/login api. 13 | It includes all commonly used configurations that would allow you to focus on adding new 14 | features to your application. 15 | 16 | [![Testing](https://github.com/cashwarden/api/workflows/Testing/badge.svg)](https://github.com/cashwarden/api/actions) 17 | [![Lint](https://github.com/cashwarden/api/workflows/Lint/badge.svg)](https://github.com/cashwarden/api/actions) 18 | [![Code Coverage](https://scrutinizer-ci.com/g/cashwarden/api/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/cashwarden/api/?branch=master) 19 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/cashwarden/api/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/cashwarden/api/?branch=master) 20 | [![Latest Stable Version](https://poser.pugx.org/cashwarden/api/v/stable)](https://packagist.org/packages/cashwarden/api) 21 | [![Total Downloads](https://poser.pugx.org/cashwarden/api/downloads)](https://packagist.org/packages/cashwarden/api) 22 | [![Latest Unstable Version](https://poser.pugx.org/cashwarden/api/v/unstable)](https://packagist.org/packages/cashwarden/api) 23 | [![License](https://poser.pugx.org/cashwarden/api/license)](https://packagist.org/packages/cashwarden/api) 24 | 25 | REQUIREMENTS 26 | ------------ 27 | 28 | The minimum requirement by this project template that your Web server supports PHP 7.2.0. 29 | 30 | INSTALLATION 31 | ------------ 32 | 33 | ### Install via Composer 34 | 35 | If you do not have [Composer](http://getcomposer.org/), you may install it by following the instructions 36 | at [getcomposer.org](http://getcomposer.org/doc/00-intro.md#installation-nix). 37 | 38 | You can then install this project template using the following command: 39 | 40 | ~~~ 41 | composer create-project --prefer-dist cashwarden/api 42 | cd 43 | cp .env.example .env 44 | chmod 777 -R runtime/ 45 | chmod 777 -R web/uploads/ 46 | php yii migrate 47 | php yii generate/key # optional 48 | ~~~ 49 | 50 | Now you should be able to access the application through the following URL, assuming `rest-api` is the directory 51 | directly under the Web root. 52 | 53 | ~~~ 54 | http://localhost//web/ 55 | ~~~ 56 | 57 | ### Install from GitHub 58 | 59 | Accessing [Use this template](https://github.com/cashwarden/api/generate) Create a new repository from yii2-rest-api 60 | 61 | ```sh 62 | git clone xxxx 63 | cd 64 | cp .env.example .env 65 | chmod 777 -R runtime/ 66 | chmod 777 -R web/uploads/ 67 | php yii migrate 68 | php yii generate/key # optional 69 | ``` 70 | 71 | You can then access the application through the following URL: 72 | 73 | ~~~ 74 | http://localhost//web/ 75 | ~~~ 76 | 77 | 78 | ### Install with Docker 79 | 80 | Update your vendor packages 81 | 82 | ```sh 83 | docker-compose run --rm php composer update --prefer-dist 84 | ``` 85 | 86 | Run the installation triggers (creating cookie validation code) 87 | 88 | ```sh 89 | docker-compose run --rm php composer install 90 | ``` 91 | 92 | Start the container 93 | 94 | ```sh 95 | docker-compose up -d 96 | ``` 97 | 98 | You can then access the application through the following URL: 99 | 100 | ``` 101 | http://127.0.0.1:8000 102 | ``` 103 | 104 | **NOTES:** 105 | - Minimum required Docker engine version `17.04` for development (see [Performance tuning for volume mounts](https://docs.docker.com/docker-for-mac/osxfs-caching/)) 106 | - The default configuration uses a host-volume in your home directory `.docker-composer` for composer caches 107 | 108 | Check out the packages 109 | ------------ 110 | 111 | - [yiithings/yii2-doten](https://github.com/yiithings/yii2-doten) 112 | - [sizeg/yii2-jwt](https://github.com/sizeg/yii2-jwt) 113 | - [yiier/yii2-helpers](https://github.com/yiier/yii2-helpers) 114 | 115 | Use 116 | ------------ 117 | 118 | At this time, you have a RESTful API server running at `http://127.0.0.1:8000`. It provides the following endpoints: 119 | 120 | * `GET /health-check`: a health check service provided for health checking purpose (needed when implementing a server cluster) 121 | * `POST /v1/join`: create a user 122 | * `POST /v1/login`: authenticates a user and generates a JWT 123 | * `POST /v1/refresh-token`: refresh a JWT 124 | 125 | Try the URL `http://localhost:8000/health-check` in a browser, and you should see something like `{"code":0,"data":"OK","message":"成功"}` displayed. 126 | 127 | If you have `cURL` or some API client tools (e.g. [Postman](https://www.getpostman.com/)), you may try the following 128 | more complex scenarios: 129 | 130 | ```shell 131 | # create a user via: POST /v1/join 132 | curl -X POST -H "Content-Type: application/json" -d '{"username":"demo","email":"demo@email.com","password":"pass123"}' http://localhost:8000/v1/join 133 | # should return like: {"code":0,"data":{"username":"demo","email":"demo@email.com","status":1,"created_at":"2020-07-18T16:38:11+08:00","updated_at":"2020-07-18T16:38:11+08:00","id":17},"message":"成功"} 134 | 135 | # authenticate the user via: POST /v1/login 136 | curl -X POST -H "Content-Type: application/json" -d '{"username": "demo", "password": "pass123"}' http://localhost:8000/v1/login 137 | # should return like: {"code":0,"data":{"user":{"id":4,"username":"dem211o1","avatar":"","email":"de21mo1@mail.com","status":1,"created_at":"2020-07-17T23:49:39+08:00","updated_at":"2020-07-17T23:49:39+08:00"},"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IllpaS1SRVNULUFQSSJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJqdGkiOiJZaWktUkVTVC1BUEkiLCJpYXQiOjE1OTUwNjQ5NzIsImV4cCI6MTU5NTMyNDE3MiwidXNlcm5hbWUiOiJkZW0yMTFvMSIsImlkIjo0fQ.y2NSVQe-TQ08RnXnF-o55h905G9WHo6GYHNaUWlKjDE"},"message":"成功"} 138 | 139 | # refresh a JWT 140 | curl -X POST -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IllpaS1SRVNULUFQSSJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJqdGkiOiJZaWktUkVTVC1BUEkiLCJpYXQiOjE1OTUwNjQ5NzIsImV4cCI6MTU5NTMyNDE3MiwidXNlcm5hbWUiOiJkZW0yMTFvMSIsImlkIjo0fQ.y2NSVQe-TQ08RnXnF-o55h905G9WHo6GYHNaUWlKjDE' http://localhost:8000/v1/refresh-token 141 | # should return like: {"code":0,"data":{"user":{"id":4,"username":"dem211o1","avatar":"","email":"de21mo1@mail.com","status":1,"created_at":"2020-07-17T23:49:39+08:00","updated_at":"2020-07-17T23:49:39+08:00"},"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IllpaS1SRVNULUFQSSJ9.eyJpc3MiOiJodHRwOlwvXC9sb2NhbGhvc3QiLCJqdGkiOiJZaWktUkVTVC1BUEkiLCJpYXQiOjE1OTUwNjQ5NzIsImV4cCI6MTU5NTMyNDE3MiwidXNlcm5hbWUiOiJkZW0yMTFvMSIsImlkIjo0fQ.y2NSVQe-TQ08RnXnF-o55h905G9WHo6GYHNaUWlKjDE"},"message":"成功"} 142 | ``` 143 | -------------------------------------------------------------------------------- /_ide_helper.php: -------------------------------------------------------------------------------- 1 | where(['status' => RecurrenceStatus::ACTIVE]) 33 | ->andWhere(['execution_date' => Yii::$app->formatter->asDatetime('now', 'php:Y-m-d')]) 34 | ->asArray() 35 | ->all(); 36 | $transaction = Yii::$app->db->beginTransaction(); 37 | try { 38 | foreach ($items as $item) { 39 | \Yii::$app->user->setIdentity(User::findOne($item['user_id'])); 40 | if ($t = $this->transactionService->copy($item['transaction_id'], $item['user_id'])) { 41 | $keyboard = $this->telegramService->getRecordMarkup($t); 42 | $text = $this->telegramService->getMessageTextByTransaction($t, '定时记账成功'); 43 | $this->telegramService->sendMessage($text, $keyboard); 44 | $this->stdout("定时记账成功,transaction_id:{$t->id}\n"); 45 | } 46 | } 47 | RecurrenceService::updateAllExecutionDate(); 48 | $transaction->commit(); 49 | } catch (\Exception $e) { 50 | $this->stdout("定时记账失败:{$e->getMessage()}\n"); 51 | $transaction->rollBack(); 52 | throw $e; 53 | } 54 | } 55 | 56 | /** 57 | * @param string $type 58 | * @throws Exception 59 | */ 60 | public function actionReport(string $type = AnalysisDateType::YESTERDAY) 61 | { 62 | $items = AuthClient::find() 63 | ->where(['type' => AuthClientType::TELEGRAM]) 64 | ->asArray() 65 | ->all(); 66 | $transaction = Yii::$app->db->beginTransaction(); 67 | try { 68 | foreach ($items as $item) { 69 | $this->telegramService->sendReport($item['user_id'], $type); 70 | $this->stdout("定时发送报告成功,user_id:{$item['user_id']}\n"); 71 | } 72 | $transaction->commit(); 73 | } catch (\Exception $e) { 74 | $this->stdout("定时发送报告失败:{$e->getMessage()}\n"); 75 | $transaction->rollBack(); 76 | throw $e; 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /commands/FixController.php: -------------------------------------------------------------------------------- 1 | stdout("{$item} key [{$key}] set successfully.\n", Console::FG_GREEN); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /commands/HelloController.php: -------------------------------------------------------------------------------- 1 | 21 | * @since 2.0 22 | */ 23 | class HelloController extends Controller 24 | { 25 | /** 26 | * This command echoes what you have entered as the message. 27 | * @param string $message the message to be echoed. 28 | * @return int Exit code 29 | */ 30 | public function actionIndex($message = 'hello world') 31 | { 32 | echo $message . "\n"; 33 | 34 | \Yii::error(['request_id' => \Yii::$app->requestId->id, 'test_request_id']); 35 | return ExitCode::OK; 36 | } 37 | 38 | public function actionTelegram() 39 | { 40 | $client = TelegramService::newClient(); 41 | dump($client->getMe()); 42 | return ExitCode::OK; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /commands/InitController.php: -------------------------------------------------------------------------------- 1 | setWebHook($url); 20 | $this->stdout("Telegram set Webhook url success!: {$url}\n"); 21 | return ExitCode::OK; 22 | } 23 | 24 | /** 25 | * @param int $userId 26 | * @throws \app\core\exceptions\InvalidArgumentException 27 | * @throws \yii\db\Exception 28 | */ 29 | public function actionUserData(int $userId) 30 | { 31 | $this->userService->createUserAfterInitData(User::findOne($userId)); 32 | $this->stdout("User Account and Category init success! \n"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cashwarden/api", 3 | "description": "CashWarden API", 4 | "keywords": [ 5 | "yii2", 6 | "framework", 7 | "rest", 8 | "basic", 9 | "project template" 10 | ], 11 | "type": "project", 12 | "license": "BSD-3-Clause", 13 | "support": { 14 | "issues": "https://github.com/forecho/yii2-rest-api/issues?state=open", 15 | "source": "https://github.com/forecho/yii2-rest-api" 16 | }, 17 | "minimum-stability": "stable", 18 | "require": { 19 | "php": ">=7.4", 20 | "yiisoft/yii2": "~2.0.14", 21 | "yiisoft/yii2-swiftmailer": "~2.0.0 || ~2.1.0", 22 | "yiithings/yii2-dotenv": "^1.0", 23 | "sizeg/yii2-jwt": "^2.0", 24 | "yiier/yii2-helpers": "^2.0", 25 | "yiier/yii2-graylog-target": "^1.1", 26 | "yiier/yii2-validators": "^0.3.0", 27 | "mis/yii2-ide-helper": "^1.0", 28 | "telegram-bot/api": "^2.3", 29 | "ext-json": "*", 30 | "ext-bcmath": "*", 31 | "guzzlehttp/guzzle": "^7.0" 32 | }, 33 | "require-dev": { 34 | "yiisoft/yii2-debug": "~2.1.0", 35 | "yiisoft/yii2-gii": "~2.1.0", 36 | "yiisoft/yii2-faker": "~2.0.0", 37 | "codeception/codeception": "^4.0", 38 | "codeception/verify": "~0.5.0 || ~1.1.0", 39 | "codeception/specify": "~0.4.6", 40 | "symfony/browser-kit": ">=2.7 <=4.2.4", 41 | "codeception/module-filesystem": "^1.0.0", 42 | "codeception/module-yii2": "^1.0.0", 43 | "codeception/module-asserts": "^1.0.0", 44 | "codeception/module-rest": "^1.0.0", 45 | "codeception/module-phpbrowser": "^1.0.0", 46 | "squizlabs/php_codesniffer": "^3.5.5", 47 | "phpro/grumphp": "^0.19.1" 48 | }, 49 | "config": { 50 | "process-timeout": 1800, 51 | "fxp-asset": { 52 | "enabled": false 53 | }, 54 | "allow-plugins": { 55 | "yiisoft/yii2-composer": true, 56 | "phpro/grumphp": true 57 | } 58 | }, 59 | "autoload": { 60 | "files": [ 61 | "vendor/yiier/yii2-helpers/src/GlobalFunctions.php", 62 | "vendor/yiier/yii2-helpers/src/SupportFunctions.php" 63 | ] 64 | }, 65 | "scripts": { 66 | "post-install-cmd": [ 67 | "yii\\composer\\Installer::postInstall" 68 | ], 69 | "post-create-project-cmd": [ 70 | "yii\\composer\\Installer::postCreateProject", 71 | "yii\\composer\\Installer::postInstall" 72 | ] 73 | }, 74 | "extra": { 75 | "yii\\composer\\Installer::postCreateProject": { 76 | "setPermission": [ 77 | { 78 | "runtime": "0777", 79 | "web/assets": "0777", 80 | "yii": "0755" 81 | } 82 | ] 83 | }, 84 | "yii\\composer\\Installer::postInstall": { 85 | "generateCookieValidationKey": [ 86 | "config/web.php" 87 | ] 88 | } 89 | }, 90 | "repositories": [ 91 | { 92 | "type": "composer", 93 | "url": "https://asset-packagist.org" 94 | } 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /config/console.php: -------------------------------------------------------------------------------- 1 | 'basic-console', 8 | 'basePath' => dirname(__DIR__), 9 | 'controllerNamespace' => 'app\commands', 10 | 'aliases' => [ 11 | '@bower' => '@vendor/bower-asset', 12 | '@npm' => '@vendor/npm-asset', 13 | '@tests' => '@app/tests', 14 | ], 15 | 'components' => [ 16 | 'user' => [ 17 | 'class' => 'yii\web\User', 18 | 'identityClass' => 'app\models\User', 19 | 'enableSession' => false, 20 | 'enableAutoLogin' => false, 21 | ], 22 | 'urlManager' => [ 23 | 'baseUrl' => env('APP_URL'), 24 | 'hostInfo' => env('APP_URL') 25 | ], 26 | ], 27 | 'params' => $params, 28 | ]; 29 | 30 | if (YII_ENV_DEV) { 31 | // configuration adjustments for 'dev' environment 32 | $config['bootstrap'][] = 'gii'; 33 | $config['modules']['gii'] = [ 34 | 'class' => 'yii\gii\Module', 35 | ]; 36 | } 37 | 38 | return \yii\helpers\ArrayHelper::merge($common, $config); 39 | -------------------------------------------------------------------------------- /config/params.php: -------------------------------------------------------------------------------- 1 | env('APP_URL'), 5 | 'adminEmail' => env('ADMIN_EMAIL'), 6 | 'senderEmail' => env('SENDER_EMAIL'), 7 | 'senderName' => env('SENDER_NAME', env('APP_NAME')), 8 | 'telegramToken' => env('TELEGRAM_TOKEN'), 9 | 'telegramBotName' => env('TELEGRAM_BOT_NAME'), 10 | 'user.passwordResetTokenExpire' => env('USER_RESET_TOKEN_EXPIRE', 3600), 11 | 'seoKeywords' => env('SEO_KEYWORDS'), 12 | 'seoDescription' => env('SEO_DESCRIPTION'), 13 | 'googleAnalyticsAU' => env('GOOGLE_ANALYTICS_AU'), 14 | // 不记录 header 指定 key 的值到日志,默认值为 authorization,配置自定义会覆盖默认值 15 | 'logFilterIgnoredHeaderKeys' => env('LOG_FILTER_IGNORED_HEADER_KEYS', 'authorization,token,cookie'), 16 | 'logFilterIgnoredKeys' => env('LOG_FILTER_IGNORED_KEYS', 'password'), // 不记录日志 17 | 'logFilterHideKeys' => env('LOG_FILTER_HIDE_KEYS'), // 用*代替所有数据 18 | 'logFilterHalfHideKeys' => env('LOG_FILTER_HALF_HIDE_KEYS', 'email'), // 部分数据隐藏,只显示头部 20% 和尾部 20% 数据,剩下的用*代替 19 | 'uploadSavePath' => '@webroot/uploads', 20 | 'uploadWebPath' => '@web/uploads', 21 | ]; 22 | -------------------------------------------------------------------------------- /config/test.php: -------------------------------------------------------------------------------- 1 | 'basic-tests', 12 | ], $web); 13 | 14 | return ArrayHelper::merge($common, $config); 15 | -------------------------------------------------------------------------------- /config/web.php: -------------------------------------------------------------------------------- 1 | 'basic', 11 | 'basePath' => dirname(__DIR__), 12 | 'aliases' => [ 13 | '@bower' => '@vendor/bower-asset', 14 | '@npm' => '@vendor/npm-asset', 15 | ], 16 | 'modules' => [ 17 | 'v1' => [ 18 | 'class' => 'app\modules\v1\Module', 19 | ], 20 | ], 21 | 'components' => [ 22 | 'request' => [ 23 | 'parsers' => [ 24 | 'application/json' => 'yii\web\JsonParser', 25 | ], 26 | // !!! insert a secret key in the following (if it is empty) - this is required by cookie validation 27 | 'cookieValidationKey' => env('COOKIE_VALIDATION_KEY') 28 | ], 29 | 'response' => [ 30 | 'class' => 'yii\web\Response', 31 | 'on beforeSend' => function ($event) { 32 | yii::createObject([ 33 | 'class' => ResponseHandler::class, 34 | 'event' => $event, 35 | ])->formatResponse(); 36 | }, 37 | ], 38 | 'jwt' => [ 39 | 'class' => \sizeg\jwt\Jwt::class, 40 | 'key' => env('JWT_SECRET'), 41 | ], 42 | 'user' => [ 43 | 'identityClass' => User::class, 44 | 'enableAutoLogin' => true, 45 | ], 46 | 'errorHandler' => [ 47 | // 'class' => '\yii\web\ErrorHandler', 48 | 'errorAction' => 'site/error', 49 | ], 50 | 'mailer' => [ 51 | 'class' => 'yii\swiftmailer\Mailer', 52 | // send all mails to a file by default. You have to set 53 | // 'useFileTransport' to false and configure a transport 54 | // for the mailer to send real emails. 55 | 'useFileTransport' => true, 56 | ], 57 | 'urlManager' => [ 58 | 'enablePrettyUrl' => true, 59 | 'showScriptName' => false, 60 | 'hostInfo' => getenv('APP_URL'), 61 | 'rules' => [ 62 | "POST /" => '/user/', 63 | "POST /token/refresh" => '/user/refresh-token', 64 | "POST /transactions/by-description" => '/transaction/create-by-description', 65 | "POST /rules//copy" => '/rule/copy', 66 | "PUT /rules//status" => '/rule/update-status', 67 | "GET /accounts/types" => '/account/types', 68 | "GET /accounts/overview" => '/account/overview', 69 | "POST /reset-token" => '/user/reset-token', 70 | "GET /users/auth-clients" => '/user/get-auth-clients', 71 | "GET /transactions/" => '/transaction/', 72 | "POST /transactions/upload" => '/transaction/upload', 73 | "GET /records/overview" => '/record/overview', 74 | "GET /categories/analysis" => '/category/analysis', 75 | "GET /records/analysis" => '/record/analysis', 76 | "GET /records/sources" => '/record/sources', 77 | "PUT /recurrences//status" => '/recurrence/update-status', 78 | "GET /recurrences/frequencies" => '/recurrence/frequency-types', 79 | 80 | "GET /site-config" => '/site/data', 81 | "GET /" => '/site/', 82 | "GET health-check" => 'site/health-check', 83 | [ 84 | 'class' => 'yii\rest\UrlRule', 85 | 'controller' => [ 86 | 'v1/account', 87 | 'v1/category', 88 | 'v1/rule', 89 | 'v1/tag', 90 | 'v1/record', 91 | 'v1/transaction', 92 | 'v1/recurrence', 93 | ] 94 | ], 95 | '///' => '//', 96 | ], 97 | ], 98 | ], 99 | 'params' => $params, 100 | ]; 101 | 102 | if (YII_ENV_DEV) { 103 | // configuration adjustments for 'dev' environment 104 | $config['bootstrap'][] = 'debug'; 105 | $config['modules']['debug'] = [ 106 | 'class' => 'yii\debug\Module', 107 | // uncomment the following to add your IP if you are not connecting from localhost. 108 | 'allowedIPs' => ['*'], 109 | ]; 110 | 111 | $config['bootstrap'][] = 'gii'; 112 | $config['modules']['gii'] = [ 113 | 'class' => 'yii\gii\Module', 114 | // uncomment the following to add your IP if you are not connecting from localhost. 115 | 'allowedIPs' => ['*'], 116 | ]; 117 | } 118 | 119 | return \yii\helpers\ArrayHelper::merge($common, $config); 120 | -------------------------------------------------------------------------------- /controllers/SiteController.php: -------------------------------------------------------------------------------- 1 | errorHandler->exception; 34 | if ($exception !== null) { 35 | Yii::error([ 36 | 'request_id' => Yii::$app->requestId->id, 37 | 'exception' => $exception->getMessage(), 38 | 'line' => $exception->getLine(), 39 | 'file' => $exception->getFile(), 40 | ], 'response_data_error'); 41 | 42 | return ['code' => $exception->getCode(), 'message' => $exception->getMessage()]; 43 | } 44 | return []; 45 | } 46 | 47 | 48 | public function actionIcons() 49 | { 50 | return [ 51 | 'food', 52 | 'home', 53 | 'bus', 54 | 'game', 55 | 'medicine-chest', 56 | 'clothes', 57 | 'education', 58 | 'investment', 59 | 'baby', 60 | 'expenses', 61 | 'work', 62 | 'income', 63 | 'transfer', 64 | 'adjust', 65 | ]; 66 | } 67 | 68 | public function actionData() 69 | { 70 | return [ 71 | 'app' => [ 72 | 'name' => Yii::$app->name, 73 | 'description' => params('seoDescription'), 74 | 'keywords' => params('seoKeywords'), 75 | 'google_analytics' => params('googleAnalyticsAU'), 76 | 'telegram_bot_name' => params('telegramBotName') 77 | ], 78 | 'menu' => [ 79 | [ 80 | 'text' => 'Main', 81 | 'group' => false, 82 | 'children' => [ 83 | [ 84 | 'text' => '仪表盘', 85 | 'link' => '/dashboard', 86 | 'icon' => 'anticon-dashboard', 87 | ], 88 | [ 89 | 'text' => '账户', 90 | 'link' => '/account/index', 91 | 'icon' => 'anticon-account-book', 92 | ], 93 | [ 94 | 'text' => '记录', 95 | 'link' => '/record/index', 96 | 'icon' => 'anticon-database', 97 | ], 98 | [ 99 | 'text' => '定时', 100 | 'link' => '/recurrence/index', 101 | 'icon' => 'anticon-field-time', 102 | ], 103 | [ 104 | 'text' => '分析', 105 | 'link' => '/analysis/index', 106 | 'icon' => 'anticon-area-chart', 107 | ], 108 | [ 109 | 'text' => '设置', 110 | 'icon' => 'anticon-setting', 111 | 'children' => [ 112 | [ 113 | 'text' => '个人设置', 114 | 'link' => '/settings/personal', 115 | 'icon' => 'anticon-user', 116 | ], 117 | [ 118 | 'text' => '分类设置', 119 | 'link' => '/settings/categories', 120 | 'icon' => 'anticon-appstore', 121 | ], 122 | [ 123 | 'text' => '标签设置', 124 | 'link' => '/settings/tags', 125 | 'icon' => 'anticon-appstore', 126 | ], 127 | [ 128 | 'text' => '规则设置', 129 | 'link' => '/settings/rules', 130 | 'icon' => 'anticon-group', 131 | ] 132 | ] 133 | ], 134 | ] 135 | ] 136 | ] 137 | ]; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /core/EventBootstrap.php: -------------------------------------------------------------------------------- 1 | beforeSend($event); 17 | }); 18 | 19 | Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, function ($event) { 20 | \Yii::createObject(LoggerBehavior::class)->beforeAction(); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/actions/CreateAction.php: -------------------------------------------------------------------------------- 1 | checkAccess) { 28 | call_user_func($this->checkAccess, $this->id); 29 | } 30 | 31 | /* @var $model \yii\db\ActiveRecord */ 32 | $model = new $this->modelClass([ 33 | 'scenario' => $this->scenario, 34 | ]); 35 | 36 | $model->load(Yii::$app->getRequest()->getBodyParams(), ''); 37 | if ($model->save()) { 38 | $response = Yii::$app->getResponse(); 39 | $response->setStatusCode(200); 40 | $id = implode(',', array_values($model->getPrimaryKey(true))); 41 | return $model->findOne($id); 42 | } elseif (!$model->hasErrors()) { 43 | throw new ServerErrorHttpException('Failed to create the object for unknown reason.'); 44 | } 45 | 46 | return $model; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/behaviors/LoggerBehavior.php: -------------------------------------------------------------------------------- 1 | 'beforeSend', 18 | Controller::EVENT_BEFORE_ACTION => 'beforeAction' 19 | ]; 20 | } 21 | 22 | /** 23 | * @param $event 24 | * @throws \Exception 25 | */ 26 | public function beforeSend($event) 27 | { 28 | $response = $event->sender; 29 | if ($response->format != 'html') { 30 | $request = \Yii::$app->request; 31 | $params = Yii::$app->params; 32 | $requestId = Yii::$app->requestId->id; 33 | $code = ArrayHelper::getValue($response->data, 'code'); 34 | 35 | $ignoredKeys = explode(',', ArrayHelper::getValue($params, 'logFilterIgnoredKeys', '')); 36 | $hideKeys = explode(',', ArrayHelper::getValue($params, 'logFilterHideKeys', '')); 37 | $halfHideKeys = explode(',', ArrayHelper::getValue($params, 'logFilterHalfHideKeys', '')); 38 | $ignoredHeaderKeys = explode(',', ArrayHelper::getValue($params, 'logFilterIgnoredHeaderKeys', '')); 39 | $requestHeaderParams = $this->headerFilter($request->headers->toArray(), $ignoredHeaderKeys); 40 | 41 | $requestParams = $this->paramsFilter($request->bodyParams, $ignoredKeys, $hideKeys, $halfHideKeys); 42 | 43 | $message = [ 44 | 'request_id' => $requestId, 45 | 'type' => $code === 0 ? 'response_data_success' : 'response_data_error', 46 | 'header' => Json::encode($requestHeaderParams), 47 | 'params' => Json::encode($requestParams), 48 | 'url' => $request->absoluteUrl, 49 | 'response' => Json::encode($response->data) 50 | ]; 51 | if (is_array($response->data)) { 52 | $response->data = ['request_id' => $requestId] + $response->data; 53 | } 54 | $code === 0 ? Yii::info($message, 'request') : Yii::error($message, 'request'); 55 | } 56 | } 57 | 58 | public function beforeAction() 59 | { 60 | return Yii::$app->requestId->id; 61 | } 62 | 63 | 64 | /** 65 | * @param array $params 66 | * @param array $ignoredHeaderKeys 67 | * @return array 68 | */ 69 | protected function headerFilter(array $params, array $ignoredHeaderKeys) 70 | { 71 | foreach ($params as $key => $item) { 72 | if ($key && in_array($key, $ignoredHeaderKeys)) { 73 | unset($params[$key]); 74 | } 75 | } 76 | return $params; 77 | } 78 | 79 | /** 80 | * @param $params array 81 | * @param array $ignoredKeys 82 | * @param array $hideKeys 83 | * @param array $halfHideKeys 84 | * @return string|int|array 85 | */ 86 | protected function paramsFilter(array $params, array $ignoredKeys, array $hideKeys, array $halfHideKeys) 87 | { 88 | if (!$hideKeys && !$halfHideKeys && !$ignoredKeys) { 89 | return $params; 90 | } 91 | foreach ($params as $key => &$item) { 92 | if (is_array($item)) { 93 | $item = $this->paramsFilter($item, $ignoredKeys, $hideKeys, $halfHideKeys); 94 | continue; 95 | } 96 | if ($key && in_array($key, $ignoredKeys)) { 97 | unset($params[$key]); 98 | } elseif ($key && in_array($key, $hideKeys)) { 99 | $item = $this->paramReplace($item); 100 | } elseif ($key && in_array($key, $halfHideKeys)) { 101 | $item = $this->paramPartialHiddenReplace($item); 102 | } 103 | } 104 | return $params; 105 | } 106 | 107 | /** 108 | * @param string $value 109 | * @return string 110 | */ 111 | protected function paramReplace(string $value) 112 | { 113 | return str_repeat('*', strlen($value)); 114 | } 115 | 116 | 117 | /** 118 | * @param $value 119 | * @return string 120 | */ 121 | protected function paramPartialHiddenReplace(string $value) 122 | { 123 | $valueLength = strlen($value); 124 | if ($valueLength > 2) { 125 | $showLength = ceil($valueLength * 0.2); 126 | $hideLength = $valueLength - $showLength * 2; 127 | $newValue = mb_substr($value, 0, $showLength) 128 | . str_repeat('*', $hideLength) 129 | . mb_substr($value, -1 * $showLength); 130 | } else { 131 | $newValue = $this->paramReplace($value); 132 | } 133 | 134 | return $newValue; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /core/components/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | event->sender; 18 | if ($response->data !== null) { 19 | if (isset($response->data['code']) && isset($response->data['message'])) { 20 | $response->data = [ 21 | 'code' => $response->data['code'] ?: $response->statusCode, 22 | 'data' => isset($response->data['data']) ? $response->data['data'] : null, 23 | 'message' => $response->data['message'], 24 | ]; 25 | } elseif ($response->format != 'html' && !isset($response->data['message'])) { 26 | $response->data = [ 27 | 'code' => 0, 28 | 'data' => $response->data, 29 | 'message' => $this->successMessage ?: \Yii::t('app', 'Success Message'), 30 | ]; 31 | } elseif ((!empty($response->data['message'])) && !isset($response->data['code'])) { 32 | $message = $response->data['message']; 33 | unset($response->data['message']); 34 | $response->data = [ 35 | 'code' => 0, 36 | 'data' => isset($response->data[0]) ? $response->data[0] : $response->data, 37 | 'message' => $message, 38 | ]; 39 | } 40 | } 41 | $this->formatHttpStatusCode($response); 42 | } 43 | 44 | public function formatHttpStatusCode(Response $response) 45 | { 46 | switch ($response->statusCode) { 47 | case 404: 48 | $response->data['code'] = 404; 49 | break; 50 | case 204: 51 | if (\Yii::$app->request->isDelete) { 52 | $response->data['code'] = 0; 53 | $response->data['data'] = null; 54 | $response->data['message'] = $this->successMessage ?: \Yii::t('app', 'Success Message'); 55 | } 56 | break; 57 | case 422: 58 | $response->data['code'] = 422; 59 | $response->data['message'] = current($response->data['data'])['message']; 60 | break; 61 | default: 62 | # code... 63 | break; 64 | } 65 | 66 | $response->setStatusCode(200); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /core/exceptions/CannotOperateException.php: -------------------------------------------------------------------------------- 1 | sendRequest('GET', $baseUrl); 26 | $data = json_decode($response); 27 | if ($data->code == 0) { 28 | return $data->workday->date; 29 | } 30 | } catch (GuzzleException $e) { 31 | Log::error('holiday error', [$response ?? [], (string)$e]); 32 | throw new ThirdPartyServiceErrorException(); 33 | } catch (\Exception $e) { 34 | Log::error('holiday error', [$response ?? [], (string)$e]); 35 | throw new ThirdPartyServiceErrorException(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/helpers/SearchHelper.php: -------------------------------------------------------------------------------- 1 | '成功', 5 | 'Incorrect username or password.' => '账号或者密码错误。', 6 | '{attribute} can only be numbers and letters.' => '{attribute}只能为数字和字母。', 7 | 'Username' => '用户名', 8 | 'Email' => '邮箱', 9 | 'Password' => '密码', 10 | 'The JWT secret must be configured first.' => '必须先配置 JWT_SECRET', 11 | 12 | 'General Account' => '一般账户', 13 | 'Cash Account' => '现金账户', 14 | 'Debit Card' => '借记卡', 15 | 'Credit Card' => '信用卡', 16 | 'Saving Account' => '储蓄账户', 17 | 'Investment Account' => '投资账户', 18 | 'Expense' => '支出', 19 | 'Income' => '收入', 20 | 'Transfer' => '转账', 21 | 'Adjust Balance' => '调整余额', 22 | 'Adjust' => '调整', 23 | # 'Cash' => '', 24 | 'Must' => '必须', 25 | 'Want' => '享乐', 26 | 'Need' => '可选', 27 | 28 | 'Current month' => '本月', 29 | 'Last month' => '上个月', 30 | 'Yesterday' => '昨天', 31 | 'Today' => '今天', 32 | 'Grand total' => '累计', 33 | 34 | 35 | 'Base Currency Code' => '基础货币', 36 | 'Food and drink' => '餐饮食品', 37 | 'Home life' => '居家生活', 38 | 'Traffic' => '行车交通', 39 | 'Recreation' => '休闲娱乐', 40 | 'Health care' => '健康医疗', 41 | 'Clothes' => '衣服饰品', 42 | 'Cultural education' => '文化教育', 43 | 'Investment expenditure' => '投资支出', 44 | 'Childcare' => '育儿', 45 | 'Other expenses' => '其他支出', 46 | 'Work income' => '工作收入', 47 | 'Investment income' => '投资收入', 48 | 'Other income' => '其他收入', 49 | 50 | 51 | 'The {attribute} has been used.' => '{attribute} 已经被使用。', 52 | 'Cannot be deleted because it has been used.' => '不能被删除,因为已经被使用。', 53 | 'Default account not found.' => '未找到默认账号', 54 | 'Accounting failed, the amount must be greater than 0.' => '记账失败,金额必须大于0。', 55 | 'The {attribute} not found.' => '{attribute} 未找到。', 56 | 'Upload file failed' => '上传文件失败', 57 | 'Category not found.' => '分类未找到', 58 | ]; 59 | -------------------------------------------------------------------------------- /core/messages/zh-CN/exception.php: -------------------------------------------------------------------------------- 1 | '参数异常', 7 | ErrorCodes::CANNOT_OPERATE_ERROR => '不能操作', 8 | ErrorCodes::THIRD_PARTY_SERVICE_ERROR => '第三方服务异常', 9 | ErrorCodes::FILE_ENCODING_ERROR => '文件编码必须为 UTF-8 格式', 10 | ]; 11 | -------------------------------------------------------------------------------- /core/models/AuthClient.php: -------------------------------------------------------------------------------- 1 | TimestampBehavior::class, 43 | 'value' => Yii::$app->formatter->asDatetime('now') 44 | ], 45 | ]; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function rules() 52 | { 53 | return [ 54 | [['user_id', 'type', 'client_id', 'status'], 'required'], 55 | [['user_id', 'type', 'status'], 'integer'], 56 | [['data'], 'string'], 57 | [['created_at', 'updated_at'], 'safe'], 58 | [['client_id', 'client_username'], 'string', 'max' => 255], 59 | [['user_id', 'type'], 'unique', 'targetAttribute' => ['user_id', 'type']], 60 | [['type', 'client_id'], 'unique', 'targetAttribute' => ['type', 'client_id']], 61 | ]; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function attributeLabels() 68 | { 69 | return [ 70 | 'id' => Yii::t('app', 'ID'), 71 | 'user_id' => Yii::t('app', 'User ID'), 72 | 'type' => Yii::t('app', 'Type'), 73 | 'client_id' => Yii::t('app', 'Client ID'), 74 | 'client_username' => Yii::t('app', 'Client Username'), 75 | 'data' => Yii::t('app', 'Data'), 76 | 'status' => Yii::t('app', 'Status'), 77 | 'created_at' => Yii::t('app', 'Created At'), 78 | 'updated_at' => Yii::t('app', 'Updated At'), 79 | ]; 80 | } 81 | 82 | public function getUser() 83 | { 84 | return $this->hasOne(User::class, ['id' => 'user_id']); 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function fields() 91 | { 92 | $fields = parent::fields(); 93 | unset($fields['status'], $fields['data'], $fields['user_id'], $fields['client_id']); 94 | 95 | $fields['type'] = function (self $model) { 96 | return AuthClientType::getName($model->type); 97 | }; 98 | 99 | $fields['created_at'] = function (self $model) { 100 | return DateHelper::datetimeToIso8601($model->created_at); 101 | }; 102 | 103 | $fields['updated_at'] = function (self $model) { 104 | return DateHelper::datetimeToIso8601($model->updated_at); 105 | }; 106 | 107 | return $fields; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /core/models/Category.php: -------------------------------------------------------------------------------- 1 | TimestampBehavior::class, 49 | 'value' => Yii::$app->formatter->asDatetime('now') 50 | ], 51 | ]; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public function rules() 58 | { 59 | return [ 60 | [['transaction_type', 'name', 'icon_name'], 'required'], 61 | [['user_id', 'status', 'default', 'sort'], 'integer'], 62 | ['transaction_type', 'in', 'range' => TransactionType::names()], 63 | [['name', 'icon_name'], 'string', 'max' => 120], 64 | ['color', 'in', 'range' => ColorType::items()], 65 | [ 66 | 'name', 67 | 'unique', 68 | 'targetAttribute' => ['user_id', 'name'], 69 | 'message' => Yii::t('app', 'The {attribute} has been used.') 70 | ], 71 | ]; 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function attributeLabels() 78 | { 79 | return [ 80 | 'id' => Yii::t('app', 'ID'), 81 | 'user_id' => Yii::t('app', 'User ID'), 82 | 'transaction_type' => Yii::t('app', 'Transaction Type'), 83 | 'name' => Yii::t('app', 'Name'), 84 | 'color' => Yii::t('app', 'Color'), 85 | 'icon_name' => Yii::t('app', 'Icon Name'), 86 | 'status' => Yii::t('app', 'Status'), 87 | 'default' => Yii::t('app', 'Default'), 88 | 'sort' => Yii::t('app', 'Sort'), 89 | 'created_at' => Yii::t('app', 'Created At'), 90 | 'updated_at' => Yii::t('app', 'Updated At'), 91 | ]; 92 | } 93 | 94 | public function beforeValidate() 95 | { 96 | if (parent::beforeValidate()) { 97 | $this->user_id = Yii::$app->user->id; 98 | return true; 99 | } 100 | return false; 101 | } 102 | 103 | 104 | /** 105 | * @param bool $insert 106 | * @return bool 107 | * @throws InvalidArgumentException 108 | */ 109 | public function beforeSave($insert) 110 | { 111 | if (parent::beforeSave($insert)) { 112 | if ($insert) { 113 | $ran = ColorType::items(); 114 | $this->color = $this->color ?: $ran[mt_rand(0, count($ran) - 1)]; 115 | } 116 | $this->transaction_type = TransactionType::toEnumValue($this->transaction_type); 117 | return true; 118 | } else { 119 | return false; 120 | } 121 | } 122 | 123 | /** 124 | * @return array 125 | */ 126 | public function fields() 127 | { 128 | $fields = parent::fields(); 129 | unset($fields['user_id']); 130 | 131 | $fields['transaction_type'] = function (self $model) { 132 | return TransactionType::getName($model->transaction_type); 133 | }; 134 | 135 | $fields['transaction_type_text'] = function (self $model) { 136 | return data_get(TransactionType::texts(), $model->transaction_type); 137 | }; 138 | 139 | $fields['created_at'] = function (self $model) { 140 | return DateHelper::datetimeToIso8601($model->created_at); 141 | }; 142 | 143 | $fields['updated_at'] = function (self $model) { 144 | return DateHelper::datetimeToIso8601($model->updated_at); 145 | }; 146 | 147 | return $fields; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /core/models/CurrencyRate.php: -------------------------------------------------------------------------------- 1 | 3], 39 | [['currency_name'], 'string', 'max' => 60], 40 | ]; 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function attributeLabels() 47 | { 48 | return [ 49 | 'id' => Yii::t('app', 'ID'), 50 | 'user_id' => Yii::t('app', 'User ID'), 51 | 'currency_code' => Yii::t('app', 'Currency Code'), 52 | 'currency_name' => Yii::t('app', 'Currency Name'), 53 | 'rate' => Yii::t('app', 'Rate'), 54 | 'status' => Yii::t('app', 'Status'), 55 | 'created_at' => Yii::t('app', 'Created At'), 56 | 'updated_at' => Yii::t('app', 'Updated At'), 57 | ]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /core/models/Rule.php: -------------------------------------------------------------------------------- 1 | TimestampBehavior::class, 55 | 'value' => Yii::$app->formatter->asDatetime('now') 56 | ], 57 | ]; 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function rules() 64 | { 65 | return [ 66 | [['name', 'if_keywords', 'then_transaction_type'], 'required'], 67 | [['user_id', 'then_category_id', 'then_from_account_id', 'then_to_account_id', 'sort'], 'integer'], 68 | ['status', 'in', 'range' => RuleStatus::names()], 69 | ['then_transaction_type', 'in', 'range' => TransactionType::names()], 70 | ['then_reimbursement_status', 'in', 'range' => ReimbursementStatus::names()], 71 | ['then_transaction_status', 'in', 'range' => TransactionStatus::names()], 72 | [['if_keywords', 'then_tags'], ArrayValidator::class], 73 | [['name'], 'string', 'max' => 255], 74 | ]; 75 | } 76 | 77 | /** 78 | * {@inheritdoc} 79 | */ 80 | public function attributeLabels() 81 | { 82 | return [ 83 | 'id' => Yii::t('app', 'ID'), 84 | 'user_id' => Yii::t('app', 'User ID'), 85 | 'name' => Yii::t('app', 'Name'), 86 | 'if_keywords' => Yii::t('app', 'If Keywords'), 87 | 'then_transaction_type' => Yii::t('app', 'Then Transaction Type'), 88 | 'then_category_id' => Yii::t('app', 'Then Category ID'), 89 | 'then_from_account_id' => Yii::t('app', 'Then From Account ID'), 90 | 'then_to_account_id' => Yii::t('app', 'Then To Account ID'), 91 | 'then_transaction_status' => Yii::t('app', 'Then Transaction Status'), 92 | 'then_reimbursement_status' => Yii::t('app', 'Then Reimbursement Status'), 93 | 'then_tags' => Yii::t('app', 'Then Tags'), 94 | 'status' => Yii::t('app', 'Status'), 95 | 'sort' => Yii::t('app', 'Sort'), 96 | 'created_at' => Yii::t('app', 'Created At'), 97 | 'updated_at' => Yii::t('app', 'Updated At'), 98 | ]; 99 | } 100 | 101 | /** 102 | * @param bool $insert 103 | * @return bool 104 | * @throws InvalidArgumentException 105 | */ 106 | public function beforeSave($insert) 107 | { 108 | if (parent::beforeSave($insert)) { 109 | if ($insert) { 110 | $this->user_id = Yii::$app->user->id; 111 | } 112 | $this->then_reimbursement_status = is_null($this->then_reimbursement_status) ? 113 | ReimbursementStatus::NONE : ReimbursementStatus::toEnumValue($this->then_reimbursement_status); 114 | $this->then_transaction_status = is_null($this->then_transaction_status) ? 115 | TransactionStatus::DONE : TransactionStatus::toEnumValue($this->then_transaction_status); 116 | 117 | $this->status = is_null($this->status) ? RuleStatus::ACTIVE : RuleStatus::toEnumValue($this->status); 118 | $this->then_transaction_type = TransactionType::toEnumValue($this->then_transaction_type); 119 | $this->if_keywords = $this->if_keywords ? implode(',', $this->if_keywords) : null; 120 | $this->then_tags = $this->then_tags ? implode(',', $this->then_tags) : null; 121 | return true; 122 | } else { 123 | return false; 124 | } 125 | } 126 | 127 | public function getThenCategory() 128 | { 129 | return $this->hasOne(Category::class, ['id' => 'then_category_id']); 130 | } 131 | 132 | /** 133 | * @return array 134 | */ 135 | public function fields() 136 | { 137 | $fields = parent::fields(); 138 | unset($fields['user_id']); 139 | 140 | $fields['then_transaction_type'] = function (self $model) { 141 | return TransactionType::getName($model->then_transaction_type); 142 | }; 143 | 144 | $fields['then_transaction_type_text'] = function (self $model) { 145 | return data_get(TransactionType::texts(), $model->then_transaction_type); 146 | }; 147 | 148 | $fields['then_tags'] = function (self $model) { 149 | return $model->then_tags ? explode(',', $model->then_tags) : []; 150 | }; 151 | $fields['thenCategory'] = function (self $model) { 152 | return $model->thenCategory; 153 | }; 154 | 155 | $fields['if_keywords'] = function (self $model) { 156 | return explode(',', $model->if_keywords); 157 | }; 158 | 159 | $fields['status'] = function (self $model) { 160 | return RuleStatus::getName($model->status); 161 | }; 162 | 163 | $fields['then_reimbursement_status'] = function (self $model) { 164 | return ReimbursementStatus::getName($model->then_reimbursement_status); 165 | }; 166 | 167 | $fields['then_transaction_status'] = function (self $model) { 168 | return TransactionStatus::getName($model->then_transaction_status); 169 | }; 170 | 171 | $fields['created_at'] = function (self $model) { 172 | return DateHelper::datetimeToIso8601($model->created_at); 173 | }; 174 | 175 | $fields['updated_at'] = function (self $model) { 176 | return DateHelper::datetimeToIso8601($model->updated_at); 177 | }; 178 | 179 | return $fields; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /core/models/Tag.php: -------------------------------------------------------------------------------- 1 | TimestampBehavior::class, 43 | 'value' => Yii::$app->formatter->asDatetime('now') 44 | ], 45 | ]; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function rules() 52 | { 53 | return [ 54 | [['name'], 'required'], 55 | [['user_id', 'count'], 'integer'], 56 | [['color'], 'string', 'max' => 7], 57 | [['name'], 'string', 'max' => 60], 58 | [ 59 | 'name', 60 | 'unique', 61 | 'targetAttribute' => ['user_id', 'name'], 62 | 'message' => Yii::t('app', 'The {attribute} has been used.') 63 | ], 64 | ]; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function attributeLabels() 71 | { 72 | return [ 73 | 'id' => Yii::t('app', 'ID'), 74 | 'user_id' => Yii::t('app', 'User ID'), 75 | 'color' => Yii::t('app', 'Color'), 76 | 'name' => Yii::t('app', 'Name'), 77 | 'count' => Yii::t('app', 'Count'), 78 | 'created_at' => Yii::t('app', 'Created At'), 79 | 'updated_at' => Yii::t('app', 'Updated At'), 80 | ]; 81 | } 82 | 83 | public function beforeValidate() 84 | { 85 | if (parent::beforeValidate()) { 86 | $this->user_id = Yii::$app->user->id; 87 | return true; 88 | } 89 | return false; 90 | } 91 | 92 | /** 93 | * @param bool $insert 94 | * @return bool 95 | */ 96 | public function beforeSave($insert) 97 | { 98 | if (parent::beforeSave($insert)) { 99 | if ($insert) { 100 | $ran = ColorType::items(); 101 | $this->color = $this->color ?: $ran[mt_rand(0, count($ran) - 1)]; 102 | } 103 | return true; 104 | } else { 105 | return false; 106 | } 107 | } 108 | 109 | /** 110 | * @param bool $insert 111 | * @param array $changedAttributes 112 | * @throws \yii\db\Exception|\Throwable 113 | */ 114 | public function afterSave($insert, $changedAttributes) 115 | { 116 | parent::afterSave($insert, $changedAttributes); 117 | 118 | if ($oldName = data_get($changedAttributes, 'name', '')) { 119 | TagService::updateTagName($oldName, $this->name); 120 | } 121 | } 122 | 123 | /** 124 | * @return bool 125 | * @throws CannotOperateException 126 | */ 127 | public function beforeDelete() 128 | { 129 | if (TransactionService::countTransactionByTag($this->name, $this->user_id)) { 130 | throw new CannotOperateException(Yii::t('app', 'Cannot be deleted because it has been used.')); 131 | } 132 | return parent::beforeDelete(); 133 | } 134 | 135 | /** 136 | * @return array 137 | */ 138 | public function fields() 139 | { 140 | $fields = parent::fields(); 141 | unset($fields['user_id']); 142 | 143 | $fields['created_at'] = function (self $model) { 144 | return DateHelper::datetimeToIso8601($model->created_at); 145 | }; 146 | 147 | $fields['updated_at'] = function (self $model) { 148 | return DateHelper::datetimeToIso8601($model->updated_at); 149 | }; 150 | 151 | return $fields; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /core/models/User.php: -------------------------------------------------------------------------------- 1 | TimestampBehavior::class, 49 | 'value' => Yii::$app->formatter->asDatetime('now') 50 | ], 51 | ]; 52 | } 53 | 54 | /** 55 | * @inheritdoc 56 | */ 57 | public function rules() 58 | { 59 | return [ 60 | ['status', 'default', 'value' => UserStatus::ACTIVE], 61 | ['status', 'in', 'range' => [UserStatus::ACTIVE, UserStatus::UNACTIVATED]], 62 | [['username'], 'string', 'max' => 60], 63 | ]; 64 | } 65 | 66 | /** 67 | * @inheritdoc 68 | */ 69 | public static function findIdentity($id) 70 | { 71 | return static::find() 72 | ->where(['id' => $id, 'status' => UserStatus::ACTIVE]) 73 | ->limit(1) 74 | ->one(); 75 | } 76 | 77 | 78 | /** 79 | * @param mixed $token 80 | * @param null $type 81 | * @return void|IdentityInterface 82 | */ 83 | public static function findIdentityByAccessToken($token, $type = null) 84 | { 85 | $userId = (string)$token->getClaim('id'); 86 | return self::findIdentity($userId); 87 | } 88 | 89 | /** 90 | * {@inheritdoc} 91 | */ 92 | public function getId() 93 | { 94 | return $this->getPrimaryKey(); 95 | } 96 | 97 | /** 98 | * {@inheritdoc} 99 | */ 100 | public function getAuthKey() 101 | { 102 | return $this->auth_key; 103 | } 104 | 105 | /** 106 | * @inheritdoc 107 | */ 108 | public function validateAuthKey($authKey) 109 | { 110 | return $this->getAuthKey() === $authKey; 111 | } 112 | 113 | /** 114 | * Validates password 115 | * 116 | * @param string $password password to validate 117 | * @return bool if password provided is valid for current user 118 | */ 119 | public function validatePassword($password) 120 | { 121 | return Yii::$app->security->validatePassword($password, $this->password_hash); 122 | } 123 | 124 | /** 125 | * Generates password hash from password and sets it to the model 126 | * 127 | * @param string $password 128 | * @throws \yii\base\Exception 129 | */ 130 | public function setPassword($password) 131 | { 132 | $this->password_hash = Yii::$app->security->generatePasswordHash($password); 133 | } 134 | 135 | /** 136 | * Generates "remember me" authentication key 137 | * @throws \yii\base\Exception 138 | */ 139 | public function generateAuthKey() 140 | { 141 | $this->auth_key = Yii::$app->security->generateRandomString(); 142 | } 143 | 144 | /** 145 | * Generates new password reset token 146 | * @throws \yii\base\Exception 147 | */ 148 | public function generatePasswordResetToken() 149 | { 150 | $this->password_reset_token = Yii::$app->security->generateRandomString() . '_' . time(); 151 | } 152 | 153 | /** 154 | * Removes password reset token 155 | */ 156 | public function removePasswordResetToken() 157 | { 158 | $this->password_reset_token = null; 159 | } 160 | 161 | /** 162 | * Finds user by password reset token 163 | * 164 | * @param string $token password reset token 165 | * @return User|array|ActiveRecord|null 166 | */ 167 | public static function findByPasswordResetToken($token) 168 | { 169 | if (!static::isPasswordResetTokenValid($token)) { 170 | return null; 171 | } 172 | 173 | return static::find() 174 | ->where(['password_reset_token' => $token, 'status' => [UserStatus::ACTIVE]]) 175 | ->one(); 176 | } 177 | 178 | /** 179 | * Finds out if password reset token is valid 180 | * 181 | * @param string $token password reset token 182 | * @return boolean 183 | */ 184 | public static function isPasswordResetTokenValid($token) 185 | { 186 | if (empty($token)) { 187 | return false; 188 | } 189 | 190 | $timestamp = (int)substr($token, strrpos($token, '_') + 1); 191 | $expire = Yii::$app->params['user.passwordResetTokenExpire']; 192 | return $timestamp + $expire >= time(); 193 | } 194 | 195 | /** 196 | * @return array 197 | */ 198 | public function fields() 199 | { 200 | $fields = parent::fields(); 201 | unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']); 202 | 203 | $fields['created_at'] = function (self $model) { 204 | return DateHelper::datetimeToIso8601($model->created_at); 205 | }; 206 | 207 | $fields['avatar'] = function (self $model) { 208 | $avatar = md5(strtolower(trim($model->avatar))); 209 | return "https://www.gravatar.com/avatar/{$avatar}?s=48"; 210 | }; 211 | 212 | $fields['updated_at'] = function (self $model) { 213 | return DateHelper::datetimeToIso8601($model->updated_at); 214 | }; 215 | 216 | return $fields; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /core/requests/JoinRequest.php: -------------------------------------------------------------------------------- 1 | User::class], 25 | ['username', 'string', 'min' => 3, 'max' => 60], 26 | 27 | ['email', 'string', 'min' => 2, 'max' => 120], 28 | ['email', 'unique', 'targetClass' => User::class], 29 | ['email', 'email'], 30 | 31 | ['password', 'required'], 32 | ['password', 'string', 'min' => 6], 33 | 34 | ['base_currency_code', 'in', 'range' => CurrencyCode::getKeys()], 35 | ]; 36 | } 37 | 38 | /** 39 | * @inheritdoc 40 | */ 41 | public function attributeLabels() 42 | { 43 | return [ 44 | 'username' => t('app', 'Username'), 45 | 'password' => t('app', 'Password'), 46 | 'email' => t('app', 'Email'), 47 | 'base_currency_code' => t('app', 'Base Currency Code'), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/requests/LoginRequest.php: -------------------------------------------------------------------------------- 1 | 'trim'], 20 | [['username', 'password'], 'required'], 21 | 22 | ['password', 'validatePassword'], 23 | ]; 24 | } 25 | 26 | public function validatePassword($attribute, $params) 27 | { 28 | if (!$this->hasErrors()) { 29 | $user = UserService::getUserByUsernameOrEmail($this->username); 30 | if (!$user || !$user->validatePassword($this->password)) { 31 | $this->addError($attribute, t('app', 'Incorrect username or password.')); 32 | } 33 | Yii::$app->user->setIdentity($user); 34 | } 35 | } 36 | 37 | /** 38 | * @inheritdoc 39 | */ 40 | public function attributeLabels() 41 | { 42 | return [ 43 | 'username' => t('app', 'Username'), 44 | 'password' => t('app', 'Password'), 45 | 'email' => t('app', 'Email'), 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /core/requests/RecurrenceUpdateStatusRequest.php: -------------------------------------------------------------------------------- 1 | RecurrenceStatus::names()], 19 | ]; 20 | } 21 | 22 | /** 23 | * @inheritdoc 24 | */ 25 | public function attributeLabels() 26 | { 27 | return [ 28 | 'status' => t('app', 'Status'), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/requests/RuleUpdateStatusRequest.php: -------------------------------------------------------------------------------- 1 | RuleStatus::names()], 19 | ]; 20 | } 21 | 22 | /** 23 | * @inheritdoc 24 | */ 25 | public function attributeLabels() 26 | { 27 | return [ 28 | 'status' => t('app', 'Status'), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/requests/TransactionCreateByDescRequest.php: -------------------------------------------------------------------------------- 1 | 255], 17 | ]; 18 | } 19 | 20 | /** 21 | * @inheritdoc 22 | */ 23 | public function attributeLabels() 24 | { 25 | return [ 26 | 'description' => t('app', 'Description'), 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /core/requests/TransactionUploadRequest.php: -------------------------------------------------------------------------------- 1 | false, 20 | 'extensions' => 'csv', 21 | 'checkExtensionByMimeType' => false, 22 | 'maxSize' => 1 * 1024 * 1024 23 | ], 24 | ]; 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function attributeLabels() 31 | { 32 | return [ 33 | 'file' => t('app', 'file'), 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/services/AccountService.php: -------------------------------------------------------------------------------- 1 | Yii::$app->user->id]; 23 | Record::deleteAll($baseConditions + ['account_id' => $account->id]); 24 | 25 | $transactionIds = Transaction::find() 26 | ->where([ 27 | 'and', 28 | $baseConditions, 29 | ['or', ['from_account_id' => $account->id], ['to_account_id' => $account->id]] 30 | ]) 31 | ->column(); 32 | Transaction::deleteAll($baseConditions + ['id' => $transactionIds]); 33 | Recurrence::deleteAll($baseConditions + ['transaction_id' => $transactionIds]); 34 | 35 | Rule::deleteAll([ 36 | 'and', 37 | $baseConditions, 38 | ['or', ['then_from_account_id' => $account->id], ['then_to_account_id' => $account->id]] 39 | ]); 40 | } 41 | 42 | /** 43 | * @param Account $account 44 | * @return Account 45 | * @throws InternalException 46 | */ 47 | public function createUpdate(Account $account): Account 48 | { 49 | try { 50 | $account->user_id = Yii::$app->user->id; 51 | if (!$account->save()) { 52 | throw new \yii\db\Exception(Setup::errorMessage($account->firstErrors)); 53 | } 54 | } catch (Exception $e) { 55 | Yii::error( 56 | ['request_id' => Yii::$app->requestId->id, $account->attributes, $account->errors, (string)$e], 57 | __FUNCTION__ 58 | ); 59 | throw new InternalException($e->getMessage()); 60 | } 61 | return Account::findOne($account->id); 62 | } 63 | 64 | 65 | /** 66 | * @param int $id 67 | * @return Account|ActiveRecord|null 68 | */ 69 | public static function findCurrentOne(int $id) 70 | { 71 | return Account::find()->where(['id' => $id]) 72 | ->andWhere(['user_id' => Yii::$app->user->id]) 73 | ->one(); 74 | } 75 | 76 | public static function getDefaultAccount(int $userId = 0) 77 | { 78 | $userId = $userId ?: Yii::$app->user->id; 79 | return Account::find() 80 | ->where(['user_id' => $userId, 'default' => Account::DEFAULT]) 81 | ->orderBy(['id' => SORT_ASC]) 82 | ->asArray() 83 | ->one(); 84 | } 85 | 86 | /** 87 | * @param int $accountId 88 | * @return bool 89 | * @throws \yii\db\Exception 90 | */ 91 | public static function updateAccountBalance(int $accountId): bool 92 | { 93 | if (!$model = self::findCurrentOne($accountId)) { 94 | throw new \yii\db\Exception('not found account'); 95 | } 96 | $model->load($model->toArray(), ''); 97 | $model->currency_balance = Setup::toYuan(self::getCalculateCurrencyBalanceCent($accountId)); 98 | if (!$model->save()) { 99 | Yii::error( 100 | ['request_id' => Yii::$app->requestId->id, $model->attributes, $model->errors], 101 | __FUNCTION__ 102 | ); 103 | throw new \yii\db\Exception('update account failure ' . Setup::errorMessage($model->firstErrors)); 104 | } 105 | return true; 106 | } 107 | 108 | 109 | /** 110 | * @param int $accountId 111 | * @return int 112 | */ 113 | public static function getCalculateCurrencyBalanceCent(int $accountId): int 114 | { 115 | $in = Record::find()->where([ 116 | 'account_id' => $accountId, 117 | 'direction' => DirectionType::INCOME, 118 | ])->sum('currency_amount_cent'); 119 | 120 | $out = Record::find()->where([ 121 | 'account_id' => $accountId, 122 | 'direction' => DirectionType::EXPENSE, 123 | ])->sum('currency_amount_cent'); 124 | 125 | return ($in - $out) ?: 0; 126 | } 127 | 128 | /** 129 | * @return array 130 | */ 131 | public static function getCurrentMap() 132 | { 133 | $accounts = Account::find()->where(['user_id' => Yii::$app->user->id])->asArray()->all(); 134 | return ArrayHelper::map($accounts, 'id', 'name'); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /core/services/CategoryService.php: -------------------------------------------------------------------------------- 1 | user->id; 18 | return Category::find() 19 | ->where(['user_id' => $userId, 'default' => Category::DEFAULT]) 20 | ->orderBy(['id' => SORT_ASC]) 21 | ->asArray() 22 | ->one(); 23 | } 24 | 25 | public static function getAdjustCategoryId(int $userId = 0) 26 | { 27 | $userId = $userId ?: Yii::$app->user->id; 28 | return Category::find() 29 | ->where(['user_id' => $userId, 'transaction_type' => TransactionType::ADJUST]) 30 | ->orderBy(['id' => SORT_ASC]) 31 | ->scalar(); 32 | } 33 | 34 | /** 35 | * @param int $id 36 | * @return Account|ActiveRecord|null 37 | * @throws NotFoundHttpException 38 | */ 39 | public static function findCurrentOne(int $id) 40 | { 41 | if (!$model = Category::find()->where(['id' => $id, 'user_id' => \Yii::$app->user->id])->one()) { 42 | throw new NotFoundHttpException('No data found'); 43 | } 44 | return $model; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public static function getCurrentMap() 51 | { 52 | $categories = Category::find()->where(['user_id' => Yii::$app->user->id])->asArray()->all(); 53 | return ArrayHelper::map($categories, 'id', 'name'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /core/services/RecurrenceService.php: -------------------------------------------------------------------------------- 1 | findCurrentOne($id); 37 | $model->load($model->toArray(), ''); 38 | if (RecurrenceStatus::ACTIVE == RecurrenceStatus::toEnumValue($status)) { 39 | $model->started_at = strtotime($model->started_at) > time() ? $model->started_at : 'now'; 40 | } 41 | $model->started_at = Yii::$app->formatter->asDate($model->started_at); 42 | $model->status = $status; 43 | if (!$model->save()) { 44 | throw new Exception(Setup::errorMessage($model->firstErrors)); 45 | } 46 | return $model; 47 | } 48 | 49 | 50 | /** 51 | * @param int $id 52 | * @return Recurrence|object 53 | * @throws NotFoundHttpException 54 | */ 55 | public function findCurrentOne(int $id): Recurrence 56 | { 57 | if (!$model = Recurrence::find()->where(['id' => $id, 'user_id' => \Yii::$app->user->id])->one()) { 58 | throw new NotFoundHttpException('No data found'); 59 | } 60 | return $model; 61 | } 62 | 63 | /** 64 | * @param Recurrence $recurrence 65 | * @return string|null 66 | * @throws InvalidConfigException 67 | * @throws \Exception 68 | */ 69 | public static function getExecutionDate(Recurrence $recurrence) 70 | { 71 | $formatter = Yii::$app->formatter; 72 | switch ($recurrence->frequency) { 73 | case RecurrenceFrequency::DAY: 74 | $date = strtotime("+1 day", strtotime($recurrence->started_at)); 75 | break; 76 | case RecurrenceFrequency::WEEK: 77 | $currentWeekDay = $formatter->asDatetime('now', 'php:N') - 1; 78 | $weekDay = $recurrence->schedule; 79 | $addDay = $currentWeekDay > $weekDay ? 7 - $currentWeekDay + $weekDay : $weekDay - $currentWeekDay; 80 | $date = strtotime("+{$addDay} day", strtotime($recurrence->started_at)); 81 | break; 82 | 83 | case RecurrenceFrequency::MONTH: 84 | $currDay = $formatter->asDatetime('now', 'php:d'); 85 | $d = $recurrence->schedule; 86 | $date = Yii::$app->formatter->asDatetime($currDay > $d ? strtotime('+1 month') : time(), 'php:Y-m'); 87 | $d = sprintf("%02d", $d); 88 | return "{$date}-{$d}"; 89 | case RecurrenceFrequency::YEAR: 90 | $m = current(explode('-', $recurrence->schedule)); 91 | $currMonth = $formatter->asDatetime('now', 'php:m'); 92 | $y = Yii::$app->formatter->asDatetime($currMonth > $m ? strtotime('+1 year') : time(), 'php:Y'); 93 | return "{$y}-{$recurrence->schedule}"; 94 | case RecurrenceFrequency::WORKING_DAY: 95 | if (($currentWeekDay = $formatter->asDatetime('now', 'php:N') - 1) > 5) { 96 | return null; 97 | } 98 | $date = strtotime("+1 day", strtotime($recurrence->started_at)); 99 | break; 100 | case RecurrenceFrequency::LEGAL_WORKING_DAY: 101 | return HolidayHelper::getNextWorkday(); 102 | default: 103 | return null; 104 | } 105 | return $formatter->asDatetime($date, 'php:Y-m-d'); 106 | } 107 | 108 | /** 109 | * @param int $transactionId 110 | * @param int $userId 111 | * @return bool|int|string|null 112 | */ 113 | public static function countByTransactionId(int $transactionId, int $userId) 114 | { 115 | return Recurrence::find() 116 | ->where(['user_id' => $userId, 'transaction_id' => $transactionId]) 117 | ->count(); 118 | } 119 | 120 | /** 121 | * @throws InvalidConfigException|ThirdPartyServiceErrorException 122 | */ 123 | public static function updateAllExecutionDate() 124 | { 125 | $items = Recurrence::find() 126 | ->where(['status' => RecurrenceStatus::ACTIVE]) 127 | ->andWhere(['!=', 'frequency', RecurrenceFrequency::LEGAL_WORKING_DAY]) 128 | ->all(); 129 | /** @var Recurrence $item */ 130 | foreach ($items as $item) { 131 | $date = self::getExecutionDate($item); 132 | Recurrence::updateAll( 133 | ['execution_date' => $date, 'updated_at' => Yii::$app->formatter->asDatetime('now')], 134 | ['id' => $item->id] 135 | ); 136 | } 137 | self::updateAllLegalWorkingDay(); 138 | } 139 | 140 | /** 141 | * @throws InvalidConfigException 142 | * @throws ThirdPartyServiceErrorException 143 | */ 144 | private static function updateAllLegalWorkingDay() 145 | { 146 | $items = Recurrence::find() 147 | ->where(['frequency' => RecurrenceFrequency::LEGAL_WORKING_DAY, 'status' => RecurrenceStatus::ACTIVE]) 148 | ->asArray() 149 | ->all(); 150 | $nextWorkday = HolidayHelper::getNextWorkday(); 151 | foreach ($items as $key => $item) { 152 | Recurrence::updateAll( 153 | ['execution_date' => $nextWorkday, 'updated_at' => Yii::$app->formatter->asDatetime('now')], 154 | ['id' => $item['id']] 155 | ); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /core/services/RuleService.php: -------------------------------------------------------------------------------- 1 | findCurrentOne($id); 23 | $rule = new Rule(); 24 | $values = $model->toArray(); 25 | $rule->load($values, ''); 26 | $rule->name = $rule->name . ' Copy'; 27 | if (!$rule->save(false)) { 28 | throw new Exception(Setup::errorMessage($rule->firstErrors)); 29 | } 30 | return Rule::findOne($rule->id); 31 | } 32 | 33 | /** 34 | * @param int $id 35 | * @param string $status 36 | * @return Rule 37 | * @throws Exception 38 | * @throws NotFoundHttpException 39 | */ 40 | public function updateStatus(int $id, string $status) 41 | { 42 | $model = $this->findCurrentOne($id); 43 | $model->load($model->toArray(), ''); 44 | $model->status = $status; 45 | if (!$model->save(false)) { 46 | throw new Exception(Setup::errorMessage($model->firstErrors)); 47 | } 48 | return $model; 49 | } 50 | 51 | 52 | /** 53 | * @param string $desc 54 | * @return Rule[] 55 | */ 56 | public function getRulesByDesc(string $desc) 57 | { 58 | $models = Rule::find() 59 | ->where(['user_id' => \Yii::$app->user->id, 'status' => RuleStatus::ACTIVE]) 60 | ->orderBy(['sort' => SORT_ASC, 'id' => SORT_DESC]) 61 | ->all(); 62 | $rules = []; 63 | /** @var Rule $model */ 64 | foreach ($models as $model) { 65 | if (ArrayHelper::strPosArr($desc, explode(',', $model->if_keywords)) !== false) { 66 | array_push($rules, $model); 67 | } 68 | } 69 | return $rules; 70 | } 71 | 72 | /** 73 | * @param int $id 74 | * @return Rule|object 75 | * @throws NotFoundHttpException 76 | */ 77 | public function findCurrentOne(int $id): Rule 78 | { 79 | if (!$model = Rule::find()->where(['id' => $id, 'user_id' => \Yii::$app->user->id])->one()) { 80 | throw new NotFoundHttpException('No data found'); 81 | } 82 | return $model; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /core/services/TagService.php: -------------------------------------------------------------------------------- 1 | user->id; 17 | return Tag::find()->select('name')->where(['user_id' => $userId])->column(); 18 | } 19 | 20 | /** 21 | * @param array $data 22 | * @return Tag 23 | * @throws Exception 24 | */ 25 | public function create(array $data) 26 | { 27 | $model = new Tag(); 28 | $model->load($data, ''); 29 | $model->user_id = Yii::$app->user->id; 30 | if (!$model->save(false)) { 31 | throw new Exception(Setup::errorMessage($model->firstErrors)); 32 | } 33 | return $model; 34 | } 35 | 36 | /** 37 | * @param array $tags 38 | * @param int $userId 39 | * @throws \yii\base\InvalidConfigException 40 | */ 41 | public static function updateCounters(array $tags, int $userId = 0) 42 | { 43 | $userId = $userId ?: Yii::$app->user->id; 44 | foreach ($tags as $tag) { 45 | $count = TransactionService::countTransactionByTag($tag, $userId); 46 | Tag::updateAll( 47 | ['count' => $count, 'updated_at' => Yii::$app->formatter->asDatetime('now')], 48 | ['user_id' => $userId, 'name' => $tag] 49 | ); 50 | } 51 | } 52 | 53 | /** 54 | * @param string $oldName 55 | * @param string $newName 56 | * @param int $userId 57 | * @throws \yii\base\InvalidConfigException 58 | */ 59 | public static function updateTagName(string $oldName, string $newName, int $userId = 0) 60 | { 61 | $userId = $userId ?: Yii::$app->user->id; 62 | $items = Transaction::find() 63 | ->where(['user_id' => $userId]) 64 | ->andWhere(new Expression('FIND_IN_SET(:tag, tags)'))->addParams([':tag' => $oldName]) 65 | ->asArray() 66 | ->all(); 67 | /** @var Transaction $item */ 68 | foreach ($items as $item) { 69 | $tags = str_replace($oldName, $newName, $item['tags']); 70 | Transaction::updateAll( 71 | ['tags' => $tags, 'updated_at' => Yii::$app->formatter->asDatetime('now')], 72 | ['id' => $item['id']] 73 | ); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /core/services/UploadService.php: -------------------------------------------------------------------------------- 1 | saveFile($uploadedFile, $filename)) { 25 | throw new \Exception(Yii::t('app', 'Upload file failed')); 26 | } 27 | $this->checkEncoding($filename); 28 | return $this->getFullFilename($filename, params('uploadWebPath')); 29 | } catch (\Exception $e) { 30 | Log::error('upload record error', [$uploadedFile, (string)$e]); 31 | throw new \Exception($e->getMessage(), $e->getCode()); 32 | } 33 | } 34 | 35 | /** 36 | * @param $uploadedFile UploadedFile 37 | * @param string $filename 38 | * @return bool 39 | * @throws \yii\base\Exception 40 | */ 41 | protected function saveFile(UploadedFile $uploadedFile, string $filename) 42 | { 43 | $filename = $this->getFullFilename($filename); 44 | $this->deleteLocalFile($filename); 45 | FileHelper::createDirectory(dirname($filename)); 46 | return $uploadedFile->saveAs($filename); 47 | } 48 | 49 | 50 | /** 51 | * @param $filename 52 | * @param $path 53 | * @return bool|string 54 | */ 55 | public function getFullFilename($filename, string $path = '') 56 | { 57 | $path = $path ?: params('uploadSavePath'); 58 | $filename = Yii::getAlias(rtrim($path, '/') . '/' . $filename); 59 | return $filename; 60 | } 61 | 62 | /** 63 | * @param string $filename 64 | */ 65 | public function deleteLocalFile(string $filename) 66 | { 67 | $fileAbsoluteName = $this->getFullFilename($filename); 68 | @unlink($fileAbsoluteName); 69 | } 70 | 71 | /** 72 | * @param string $filename 73 | * @param string $encoding 74 | * @throws FileException 75 | */ 76 | public function checkEncoding(string $filename, $encoding = 'UTF-8') 77 | { 78 | $fileAbsoluteName = $this->getFullFilename($filename); 79 | if (!mb_check_encoding(file_get_contents($fileAbsoluteName), $encoding)) { 80 | @unlink($fileAbsoluteName); 81 | throw new FileException(Yii::t('app/error', ErrorCodes::FILE_ENCODING_ERROR)); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /core/traits/FixDataTrait.php: -------------------------------------------------------------------------------- 1 | db->beginTransaction() : null; 31 | $count = $query->count(); 32 | try { 33 | for ($i = 0; $i <= (int)ceil($count / $this->onceCount); $i++) { 34 | $items = $query 35 | ->orderBy(['id' => SORT_ASC]) 36 | ->limit($this->onceCount) 37 | ->offset($i * $this->onceCount) 38 | ->all(); 39 | // item is array 40 | foreach ($items as $item) { 41 | if (!call_user_func($existCallback, $item)) { 42 | // todo batch insert 43 | $ids[] = call_user_func($callback, $item); 44 | } 45 | } 46 | } 47 | $userTransaction ? $transaction->commit() : null; 48 | sleep($this->sleepSecond); 49 | return $ids ?? []; 50 | } catch (\Exception $e) { 51 | $userTransaction ? $transaction->rollBack() : null; 52 | \Yii::error('修复数据失败', ['query' => $query, (string)$e]); 53 | throw $e; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /core/traits/SendRequestTrait.php: -------------------------------------------------------------------------------- 1 | $data, 27 | 'timeout' => 10000, // Response timeout 28 | 'connect_timeout' => 10000, // Connection timeout 29 | ]; 30 | $client = new \GuzzleHttp\Client(['verify' => false]); 31 | $response = $client->request($type, $apiUrl, array_merge($baseOptions, $options)); 32 | Log::info('Curl 请求服务开始', ['url' => $apiUrl, $data, (array)$response]); 33 | } catch (\Exception $e) { 34 | Log::error('Curl 请求服务异常', ['url' => $apiUrl, 'exception' => (string)$e, $data]); 35 | throw new ErrorException('Curl 请求异常:' . $e->getMessage(), 500001); 36 | } 37 | if ($response->getStatusCode() == 200) { 38 | // 记录 curl 耗时 39 | $endMillisecond = round(microtime(true) * 1000); 40 | $context = [ 41 | 'curlUrl' => $apiUrl, 42 | 'CurlSpendingMillisecond' => $endMillisecond - $beginMillisecond 43 | ]; 44 | Log::info('curl time consuming', [$context, $data,]); 45 | return (string)$response->getBody(); 46 | } else { 47 | Log::error('Curl 请求服务成功,但是操作失败', ['url' => $apiUrl, 'data' => $data]); 48 | throw new ErrorException('Curl 请求服务成功,但是操作失败:'); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /core/traits/ServiceTrait.php: -------------------------------------------------------------------------------- 1 | 5 | * createTime : 2019/5/12 4:58 PM 6 | * description: 7 | */ 8 | 9 | namespace app\core\traits; 10 | 11 | use app\core\services\AccountService; 12 | use app\core\services\AnalysisService; 13 | use app\core\services\CategoryService; 14 | use app\core\services\RecurrenceService; 15 | use app\core\services\RuleService; 16 | use app\core\services\TagService; 17 | use app\core\services\TelegramService; 18 | use app\core\services\TransactionService; 19 | use app\core\services\UploadService; 20 | use app\core\services\UserService; 21 | use Yii; 22 | use yii\base\InvalidConfigException; 23 | 24 | /** 25 | * Trait ServiceTrait 26 | * @property UserService $userService 27 | * @property AccountService $accountService 28 | * @property TransactionService $transactionService 29 | * @property CategoryService $categoryService 30 | * @property RuleService $ruleService 31 | * @property TelegramService $telegramService 32 | * @property AnalysisService $analysisService 33 | * @property TagService $tagService 34 | * @property RecurrenceService $recurrenceService 35 | * @property UploadService $uploadService 36 | */ 37 | trait ServiceTrait 38 | { 39 | /** 40 | * @return UserService|object 41 | */ 42 | public function getUserService() 43 | { 44 | try { 45 | return Yii::createObject(UserService::class); 46 | } catch (InvalidConfigException $e) { 47 | return new UserService(); 48 | } 49 | } 50 | 51 | 52 | /** 53 | * @return AccountService|object 54 | */ 55 | public function getAccountService() 56 | { 57 | try { 58 | return Yii::createObject(AccountService::class); 59 | } catch (InvalidConfigException $e) { 60 | return new AccountService(); 61 | } 62 | } 63 | 64 | /** 65 | * @return TransactionService|object 66 | */ 67 | public function getTransactionService() 68 | { 69 | try { 70 | return Yii::createObject(TransactionService::class); 71 | } catch (InvalidConfigException $e) { 72 | return new TransactionService(); 73 | } 74 | } 75 | 76 | /** 77 | * @return CategoryService|object 78 | */ 79 | public function getCategoryService() 80 | { 81 | try { 82 | return Yii::createObject(CategoryService::class); 83 | } catch (InvalidConfigException $e) { 84 | return new CategoryService(); 85 | } 86 | } 87 | 88 | /** 89 | * @return RuleService|object 90 | */ 91 | public function getRuleService() 92 | { 93 | try { 94 | return Yii::createObject(RuleService::class); 95 | } catch (InvalidConfigException $e) { 96 | return new RuleService(); 97 | } 98 | } 99 | 100 | 101 | /** 102 | * @return TelegramService|object 103 | */ 104 | public function getTelegramService() 105 | { 106 | try { 107 | return Yii::createObject(TelegramService::class); 108 | } catch (InvalidConfigException $e) { 109 | return new TelegramService(); 110 | } 111 | } 112 | 113 | /** 114 | * @return AnalysisService|object 115 | */ 116 | public function getAnalysisService() 117 | { 118 | try { 119 | return Yii::createObject(AnalysisService::class); 120 | } catch (InvalidConfigException $e) { 121 | return new AnalysisService(); 122 | } 123 | } 124 | 125 | /** 126 | * @return TagService|object 127 | */ 128 | public function getTagService() 129 | { 130 | try { 131 | return Yii::createObject(TagService::class); 132 | } catch (InvalidConfigException $e) { 133 | return new TagService(); 134 | } 135 | } 136 | 137 | /** 138 | * @return RecurrenceService|object 139 | */ 140 | public function getRecurrenceService() 141 | { 142 | try { 143 | return Yii::createObject(RecurrenceService::class); 144 | } catch (InvalidConfigException $e) { 145 | return new RecurrenceService(); 146 | } 147 | } 148 | 149 | /** 150 | * @return UploadService|object 151 | */ 152 | public function getUploadService() 153 | { 154 | try { 155 | return Yii::createObject(UploadService::class); 156 | } catch (InvalidConfigException $e) { 157 | return new UploadService(); 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /core/types/AccountStatus.php: -------------------------------------------------------------------------------- 1 | 'general_account', 31 | self::CASH_ACCOUNT => 'cash_account', 32 | self::DEBIT_CARD => 'debit_card', 33 | self::CREDIT_CARD => 'credit_card', 34 | self::SAVING_ACCOUNT => 'saving_account', 35 | self::INVESTMENT_ACCOUNT => 'investment_account', 36 | ]; 37 | } 38 | 39 | public static function texts(): array 40 | { 41 | return [ 42 | self::GENERAL_ACCOUNT => Yii::t('app', 'General Account'), 43 | self::CASH_ACCOUNT => Yii::t('app', 'Cash Account'), 44 | self::DEBIT_CARD => Yii::t('app', 'Debit Card'), 45 | self::CREDIT_CARD => Yii::t('app', 'Credit Card'), 46 | self::SAVING_ACCOUNT => Yii::t('app', 'Saving Account'), 47 | self::INVESTMENT_ACCOUNT => Yii::t('app', 'Investment Account'), 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /core/types/AnalysisDateType.php: -------------------------------------------------------------------------------- 1 | Yii::t('app', 'Today'), 35 | self::YESTERDAY => Yii::t('app', 'Yesterday'), 36 | self::CURRENT_MONTH => Yii::t('app', 'Current month'), 37 | self::LAST_MONTH => Yii::t('app', 'Last month'), 38 | self::GRAND_TOTAL => Yii::t('app', 'Grand total') 39 | ]; 40 | } 41 | 42 | 43 | /** 44 | * @param string $dateStr 45 | * @return array 46 | * @throws InvalidConfigException 47 | * @throws \Exception 48 | */ 49 | public static function getEveryDayByMonth(string $dateStr) 50 | { 51 | $formatter = Yii::$app->formatter; 52 | $items = []; 53 | [$y, $m, $lastDay] = explode('-', date("Y-m-t", strtotime($dateStr))); 54 | for ($i = 1; $i <= $lastDay; $i++) { 55 | $time = date("{$y}-{$m}-" . sprintf("%02d", $i)); 56 | $date = [DateHelper::beginTimestamp($time), DateHelper::endTimestamp($time)]; 57 | $items[] = array_map(function ($i) use ($formatter) { 58 | return $formatter->asDatetime($i); 59 | }, $date); 60 | } 61 | return $items; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /core/types/AnalysisGroupDateType.php: -------------------------------------------------------------------------------- 1 | '%Y-%m-%d', 22 | self::MONTH => '%Y-%m', 23 | self::YEAR => '%Y', 24 | ]; 25 | try { 26 | return $items[$key]; 27 | } catch (\ErrorException $e) { 28 | throw new InvalidArgumentException(sprintf('Invalid: %s const value %s', __CLASS__, $key)); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/types/AuthClientStatus.php: -------------------------------------------------------------------------------- 1 | 'telegram', 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /core/types/BaseStatus.php: -------------------------------------------------------------------------------- 1 | 'active', 17 | self::UNACTIVATED => 'unactivated', 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/types/BaseType.php: -------------------------------------------------------------------------------- 1 | t('app', 'Yuan Renminbi'), 15 | 'USD' => t('app', 'US Dollar'), 16 | 'EUR' => t('app', 'Euro'), 17 | 'GBP' => t('app', 'Pound Sterling'), 18 | 'JPY' => t('app', 'Japanese Yen'), 19 | 'AUD' => t('app', 'Australian Dollar'), 20 | 'CAD' => t('app', 'Canadian Dollar'), 21 | 'CHF' => t('app', 'Swiss Franc'), 22 | 'HKD' => t('app', 'Hong Kong Dollar'), 23 | 'SEK' => t('app', 'Swedish Krona'), 24 | ]; 25 | } 26 | 27 | /** 28 | * @return array 29 | */ 30 | public static function getKeys(): array 31 | { 32 | return array_keys(self::names()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /core/types/DirectionType.php: -------------------------------------------------------------------------------- 1 | 'expense', 14 | self::INCOME => 'income', 15 | ]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /core/types/RecordSource.php: -------------------------------------------------------------------------------- 1 | 'web', 16 | self::TELEGRAM => 'telegram', 17 | self::CRONTAB => 'crontab', 18 | self::IMPORT => 'import', 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /core/types/RecurrenceFrequency.php: -------------------------------------------------------------------------------- 1 | 'day', 19 | self::WEEK => 'week', 20 | self::MONTH => 'month', 21 | self::YEAR => 'year', 22 | self::WORKING_DAY => 'working_day', 23 | self::LEGAL_WORKING_DAY => 'legal_working_day', 24 | ]; 25 | } 26 | 27 | public static function texts() 28 | { 29 | return [ 30 | self::DAY => '每天', 31 | self::WEEK => '每周', 32 | self::MONTH => '每月', 33 | self::YEAR => '每年', 34 | self::WORKING_DAY => '工作日(周一至周五)', 35 | self::LEGAL_WORKING_DAY => '法定工作日', 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/types/RecurrenceStatus.php: -------------------------------------------------------------------------------- 1 | 'none', 20 | self::TODO => 'todo', 21 | self::DONE => 'done', 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /core/types/RuleStatus.php: -------------------------------------------------------------------------------- 1 | 'must', 17 | self::NEED => 'need', 18 | self::WANT => 'want', 19 | ]; 20 | } 21 | 22 | public static function texts(): array 23 | { 24 | return [ 25 | self::MUST => Yii::t('app', 'Must'), 26 | self::NEED => Yii::t('app', 'Need'), 27 | self::WANT => Yii::t('app', 'Want'), 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /core/types/TransactionStatus.php: -------------------------------------------------------------------------------- 1 | 'todo', 17 | self::DONE => 'done', 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/types/TransactionType.php: -------------------------------------------------------------------------------- 1 | 'expense', 18 | self::INCOME => 'income', 19 | self::TRANSFER => 'transfer', 20 | self::ADJUST => 'adjust', 21 | ]; 22 | } 23 | 24 | public static function texts(): array 25 | { 26 | return [ 27 | self::EXPENSE => Yii::t('app', 'Expense'), 28 | self::INCOME => Yii::t('app', 'Income'), 29 | self::TRANSFER => Yii::t('app', 'Transfer'), 30 | self::ADJUST => Yii::t('app', 'Adjust Balance'), 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /core/types/UserStatus.php: -------------------------------------------------------------------------------- 1 | 2 | # The ServerName directive sets the request scheme, hostname and port that 3 | # the server uses to identify itself. This is used when creating 4 | # redirection URLs. In the context of virtual hosts, the ServerName 5 | # specifies what hostname must appear in the request's Host: header to 6 | # match this virtual host. For the default virtual host (this file) this 7 | # value is not decisive as it is used as a last resort host regardless. 8 | # However, you must set it for any further virtual host explicitly. 9 | #ServerName www.example.com 10 | 11 | ServerAdmin webmaster@localhost 12 | DocumentRoot /srv/web 13 | 14 | # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, 15 | # error, crit, alert, emerg. 16 | # It is also possible to configure the loglevel for particular 17 | # modules, e.g. 18 | #LogLevel info ssl:warn 19 | 20 | ErrorLog ${APACHE_LOG_DIR}/error.log 21 | CustomLog ${APACHE_LOG_DIR}/access.log combined 22 | 23 | 24 | 25 | 26 | Options Indexes FollowSymLinks 27 | AllowOverride All 28 | Require all granted 29 | 30 | 31 | 32 | # For most configuration files from conf-available/, which are 33 | # enabled or disabled at a global level, it is possible to 34 | # include a line for only one particular virtual host. For example the 35 | # following line enables the CGI configuration for this host only 36 | # after it has been globally disabled with "a2disconf". 37 | #Include conf-available/serve-cgi-bin.conf 38 | 39 | 40 | # vim: syntax=apache ts=4 sw=4 sts=4 sr noet 41 | -------------------------------------------------------------------------------- /image-files/etc/apache2/sites-available/default-ssl.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | ServerAdmin webmaster@localhost 4 | 5 | DocumentRoot /srv/web 6 | 7 | # Available loglevels: trace8, ..., trace1, debug, info, notice, warn, 8 | # error, crit, alert, emerg. 9 | # It is also possible to configure the loglevel for particular 10 | # modules, e.g. 11 | #LogLevel info ssl:warn 12 | 13 | ErrorLog ${APACHE_LOG_DIR}/error.log 14 | CustomLog ${APACHE_LOG_DIR}/access.log combined 15 | 16 | # For most configuration files from conf-available/, which are 17 | # enabled or disabled at a global level, it is possible to 18 | # include a line for only one particular virtual host. For example the 19 | # following line enables the CGI configuration for this host only 20 | # after it has been globally disabled with "a2disconf". 21 | #Include conf-available/serve-cgi-bin.conf 22 | 23 | # SSL Engine Switch: 24 | # Enable/Disable SSL for this virtual host. 25 | SSLEngine on 26 | 27 | # A self-signed (snakeoil) certificate can be created by installing 28 | # the ssl-cert package. See 29 | # /usr/share/doc/apache2/README.Debian.gz for more info. 30 | # If both key and certificate are stored in the same file, only the 31 | # SSLCertificateFile directive is needed. 32 | SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem 33 | SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key 34 | 35 | # Server Certificate Chain: 36 | # Point SSLCertificateChainFile at a file containing the 37 | # concatenation of PEM encoded CA certificates which form the 38 | # certificate chain for the server certificate. Alternatively 39 | # the referenced file can be the same as SSLCertificateFile 40 | # when the CA certificates are directly appended to the server 41 | # certificate for convinience. 42 | #SSLCertificateChainFile /etc/apache2/ssl.crt/server-ca.crt 43 | 44 | # Certificate Authority (CA): 45 | # Set the CA certificate verification path where to find CA 46 | # certificates for client authentication or alternatively one 47 | # huge file containing all of them (file must be PEM encoded) 48 | # Note: Inside SSLCACertificatePath you need hash symlinks 49 | # to point to the certificate files. Use the provided 50 | # Makefile to update the hash symlinks after changes. 51 | #SSLCACertificatePath /etc/ssl/certs/ 52 | #SSLCACertificateFile /etc/apache2/ssl.crt/ca-bundle.crt 53 | 54 | # Certificate Revocation Lists (CRL): 55 | # Set the CA revocation path where to find CA CRLs for client 56 | # authentication or alternatively one huge file containing all 57 | # of them (file must be PEM encoded) 58 | # Note: Inside SSLCARevocationPath you need hash symlinks 59 | # to point to the certificate files. Use the provided 60 | # Makefile to update the hash symlinks after changes. 61 | #SSLCARevocationPath /etc/apache2/ssl.crl/ 62 | #SSLCARevocationFile /etc/apache2/ssl.crl/ca-bundle.crl 63 | 64 | # Client Authentication (Type): 65 | # Client certificate verification type and depth. Types are 66 | # none, optional, require and optional_no_ca. Depth is a 67 | # number which specifies how deeply to verify the certificate 68 | # issuer chain before deciding the certificate is not valid. 69 | #SSLVerifyClient require 70 | #SSLVerifyDepth 10 71 | 72 | # SSL Engine Options: 73 | # Set various options for the SSL engine. 74 | # o FakeBasicAuth: 75 | # Translate the client X.509 into a Basic Authorisation. This means that 76 | # the standard Auth/DBMAuth methods can be used for access control. The 77 | # user name is the `one line' version of the client's X.509 certificate. 78 | # Note that no password is obtained from the user. Every entry in the user 79 | # file needs this password: `xxj31ZMTZzkVA'. 80 | # o ExportCertData: 81 | # This exports two additional environment variables: SSL_CLIENT_CERT and 82 | # SSL_SERVER_CERT. These contain the PEM-encoded certificates of the 83 | # server (always existing) and the client (only existing when client 84 | # authentication is used). This can be used to import the certificates 85 | # into CGI scripts. 86 | # o StdEnvVars: 87 | # This exports the standard SSL/TLS related `SSL_*' environment variables. 88 | # Per default this exportation is switched off for performance reasons, 89 | # because the extraction step is an expensive operation and is usually 90 | # useless for serving static content. So one usually enables the 91 | # exportation for CGI and SSI requests only. 92 | # o OptRenegotiate: 93 | # This enables optimized SSL connection renegotiation handling when SSL 94 | # directives are used in per-directory context. 95 | #SSLOptions +FakeBasicAuth +ExportCertData +StrictRequire 96 | 97 | SSLOptions +StdEnvVars 98 | 99 | 100 | SSLOptions +StdEnvVars 101 | 102 | 103 | # SSL Protocol Adjustments: 104 | # The safe and default but still SSL/TLS standard compliant shutdown 105 | # approach is that mod_ssl sends the close notify alert but doesn't wait for 106 | # the close notify alert from client. When you need a different shutdown 107 | # approach you can use one of the following variables: 108 | # o ssl-unclean-shutdown: 109 | # This forces an unclean shutdown when the connection is closed, i.e. no 110 | # SSL close notify alert is send or allowed to received. This violates 111 | # the SSL/TLS standard but is needed for some brain-dead browsers. Use 112 | # this when you receive I/O errors because of the standard approach where 113 | # mod_ssl sends the close notify alert. 114 | # o ssl-accurate-shutdown: 115 | # This forces an accurate shutdown when the connection is closed, i.e. a 116 | # SSL close notify alert is send and mod_ssl waits for the close notify 117 | # alert of the client. This is 100% SSL/TLS standard compliant, but in 118 | # practice often causes hanging connections with brain-dead browsers. Use 119 | # this only for browsers where you know that their SSL implementation 120 | # works correctly. 121 | # Notice: Most problems of broken clients are also related to the HTTP 122 | # keep-alive facility, so you usually additionally want to disable 123 | # keep-alive for those clients, too. Use variable "nokeepalive" for this. 124 | # Similarly, one has to force some clients to use HTTP/1.0 to workaround 125 | # their broken HTTP/1.1 implementation. Use variables "downgrade-1.0" and 126 | # "force-response-1.0" for this. 127 | # BrowserMatch "MSIE [2-6]" \ 128 | # nokeepalive ssl-unclean-shutdown \ 129 | # downgrade-1.0 force-response-1.0 130 | 131 | 132 | 133 | 134 | # vim: syntax=apache ts=4 sw=4 sts=4 sr noet 135 | -------------------------------------------------------------------------------- /image-files/root/.bashrc: -------------------------------------------------------------------------------- 1 | cat <<'MSG' 2 | _ _ __ _ 3 | (_|_)/ _| | | 4 | _ _ _ _| |_ _ __ __ _ _ __ ___ _____ _____ _ __| | __ 5 | | | | | | | _| '__/ _` | '_ ` _ \ / _ \ \ /\ / / _ \| '__| |/ / 6 | | |_| | | | | | | | (_| | | | | | | __/\ V V / (_) | | | < 7 | \__, |_|_|_| |_| \__,_|_| |_| |_|\___| \_/\_/ \___/|_| |_|\_\ 8 | __/ | 9 | |___/ 10 | 11 | MSG 12 | 13 | echo "PHP version: ${PHP_VERSION}" 14 | -------------------------------------------------------------------------------- /image-files/usr/local/bin/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | printf "Checking database connection...\n\n" 4 | mysql_ready() { 5 | mysqladmin ping --host=mysql --user=root --password=$MYSQL_ROOT_PASSWORD >/dev/null 2>&1 6 | } 7 | 8 | while !(mysql_ready); do 9 | sleep 3 10 | echo "Waiting for database connection ..." 11 | done 12 | 13 | printf "Upgrading database...\n\n" 14 | ./yii migrate/up --interactive=0 15 | 16 | touch /srv/.migrated 17 | 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /image-files/usr/local/etc/php/conf.d/base.ini: -------------------------------------------------------------------------------- 1 | ; Required timezone 2 | date.timezone = Australia/Melbourne 3 | 4 | ; General settings 5 | memory_limit = 256M 6 | max_execution_time = 300 7 | sys_temp_dir = /tmp 8 | upload_max_filesize = 512M 9 | upload_tmp_dir = /tmp 10 | post_max_size = 512M 11 | 12 | ; Security, Debug & Logs 13 | expose_php = off 14 | cgi.fix_pathinfo = 0 15 | log_errors = on 16 | error_reporting = E_ALL 17 | html_errors = on 18 | xdebug.default_enable = off 19 | 20 | ; Opcache 21 | opcache.memory_consumption = 128 22 | opcache.interned_strings_buffer = 8 23 | opcache.max_accelerated_files = 4000 24 | ;opcache.validate_timestamps=off 25 | opcache.fast_shutdown = 0 26 | opcache.enable_cli = 1 27 | 28 | ; PHP language options 29 | short_open_tag = 0 30 | -------------------------------------------------------------------------------- /image-files/usr/local/etc/php/conf.d/xdebug.ini: -------------------------------------------------------------------------------- 1 | ;zend_extension=xdebug.so 2 | xdebug.remote_enable = 0 3 | xdebug.remote_autostart = 0 4 | xdebug.remote_connect_back = 1 5 | xdebug.remote_port = 9000 6 | xdebug.idekey = PHPStorm 7 | xdebug.extended_info = 1 8 | -------------------------------------------------------------------------------- /mail/layouts/html.php: -------------------------------------------------------------------------------- 1 | 9 | beginPage() ?> 10 | 11 | 12 | 13 | 14 | <?= Html::encode($this->title) ?> 15 | head() ?> 16 | 17 | 18 | beginBody() ?> 19 | 20 | endBody() ?> 21 | 22 | 23 | endPage() ?> 24 | -------------------------------------------------------------------------------- /migrations/m200717_082932_create_user_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%user}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'username' => $this->string(60)->notNull()->unique(), 18 | 'avatar' => $this->string()->defaultValue(''), 19 | 'email' => $this->string(120)->notNull()->unique(), 20 | 'auth_key' => $this->string()->notNull(), 21 | 'password_hash' => $this->string()->notNull(), 22 | 'password_reset_token' => $this->string()->defaultValue(''), 23 | 'status' => $this->tinyInteger()->defaultValue(0), 24 | 'base_currency_code' => $this->string(3)->notNull(), 25 | 'created_at' => $this->timestamp()->defaultValue(null), 26 | 'updated_at' => $this->timestamp()->defaultValue(null), 27 | ]); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function safeDown() 34 | { 35 | $this->dropTable('{{%user}}'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/m200730_062450_create_account_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%account}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'name' => $this->string(120)->notNull(), 19 | 'type' => $this->tinyInteger()->notNull(), 20 | 'color' => $this->string(7)->notNull(), 21 | 'balance_cent' => $this->bigInteger()->defaultValue(0), 22 | 'currency_code' => $this->string(3)->notNull(), 23 | 'status' => $this->tinyInteger()->defaultValue(1), 24 | 'exclude_from_stats' => $this->tinyInteger()->defaultValue(0), 25 | 'credit_card_limit' => $this->integer(), 26 | 'credit_card_repayment_day' => $this->tinyInteger(), 27 | 'credit_card_billing_day' => $this->tinyInteger(), 28 | 'created_at' => $this->timestamp()->defaultValue(null), 29 | 'updated_at' => $this->timestamp()->defaultValue(null), 30 | ]); 31 | 32 | $this->createIndex('account_user_id', '{{%account}}', 'user_id'); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function safeDown() 39 | { 40 | $this->dropTable('{{%account}}'); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /migrations/m200730_065801_create_category_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%category}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'direction' => $this->tinyInteger()->notNull(), 19 | 'name' => $this->string(120)->notNull(), 20 | 'color' => $this->string(7)->notNull(), 21 | 'icon_name' => $this->string(120)->notNull(), 22 | 'status' => $this->tinyInteger()->defaultValue(1), 23 | 'created_at' => $this->timestamp()->defaultValue(null), 24 | 'updated_at' => $this->timestamp()->defaultValue(null), 25 | ]); 26 | 27 | $this->createIndex('category_user_id', '{{%category}}', 'user_id'); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function safeDown() 34 | { 35 | $this->dropTable('{{%category}}'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/m200730_082622_create_currency_rate_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%currency_rate}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'currency_code' => $this->string(3)->notNull(), 19 | 'currency_name' => $this->string(60)->notNull(), 20 | 'rate' => $this->bigInteger(), 21 | 'status' => $this->tinyInteger()->defaultValue(1), 22 | 'created_at' => $this->timestamp()->defaultValue(null), 23 | 'updated_at' => $this->timestamp()->defaultValue(null), 24 | ]); 25 | 26 | $this->createIndex('currency_rate_user_id', '{{%currency_rate}}', 'user_id'); 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function safeDown() 33 | { 34 | $this->dropTable('{{%currency_rate}}'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /migrations/m200730_085233_create_tag_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%tag}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'color' => $this->string(7)->notNull(), 19 | 'name' => $this->string(60)->notNull(), 20 | 'count' => $this->integer()->defaultValue(0), 21 | 'created_at' => $this->timestamp()->defaultValue(null), 22 | 'updated_at' => $this->timestamp()->defaultValue(null), 23 | ]); 24 | 25 | $this->createIndex('tag_user_id', '{{%tag}}', 'user_id'); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function safeDown() 32 | { 33 | $this->dropTable('{{%tag}}'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /migrations/m200812_090059_add_default_column.php: -------------------------------------------------------------------------------- 1 | addColumn( 16 | '{{%category}}', 17 | 'default', 18 | $this->tinyInteger()->defaultValue(0)->after('status') 19 | ); 20 | 21 | $this->addColumn( 22 | '{{%account}}', 23 | 'default', 24 | $this->tinyInteger()->defaultValue(0)->after('credit_card_billing_day') 25 | ); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function safeDown() 32 | { 33 | echo "m200812_090059_add_default_column cannot be reverted.\n"; 34 | $this->dropColumn('{{%category}}', 'default'); 35 | $this->dropColumn('{{%account}}', 'default'); 36 | return true; 37 | } 38 | 39 | /* 40 | // Use up()/down() to run migration code without a transaction. 41 | public function up() 42 | { 43 | 44 | } 45 | 46 | public function down() 47 | { 48 | echo "m200812_090059_add_default_column cannot be reverted.\n"; 49 | 50 | return false; 51 | } 52 | */ 53 | } 54 | -------------------------------------------------------------------------------- /migrations/m200814_032606_create_rule_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%rule}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'name' => $this->string()->notNull(), 19 | 'if_keywords' => $this->string()->notNull()->comment('Multiple choice use,'), 20 | 'if_direction' => $this->tinyInteger()->defaultValue(0)->comment('0:any'), 21 | 'then_direction' => $this->tinyInteger(), 22 | 'then_category_id' => $this->integer(), 23 | 'then_account_id' => $this->integer(), 24 | 'then_transaction_status' => $this->tinyInteger(), 25 | 'then_reimbursement_status' => $this->tinyInteger(), 26 | 'then_tags' => $this->string()->comment('Multiple choice use,'), 27 | 'status' => $this->tinyInteger()->defaultValue(1), 28 | 'created_at' => $this->timestamp()->defaultValue(null), 29 | 'updated_at' => $this->timestamp()->defaultValue(null), 30 | ]); 31 | 32 | $this->createIndex('rule_user_id', '{{%rule}}', 'user_id'); 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function safeDown() 39 | { 40 | $this->dropTable('{{%rule}}'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /migrations/m200818_021303_update_rules_table.php: -------------------------------------------------------------------------------- 1 | renameColumn('{{%rule}}', 'then_account_id', 'then_from_account_id'); 16 | $this->addColumn('{{%rule}}', 'then_to_account_id', $this->integer()->after('then_from_account_id')); 17 | $this->renameColumn('{{%rule}}', 'if_direction', 'then_transaction_type'); 18 | $this->dropColumn('{{%rule}}', 'then_direction'); 19 | } 20 | 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function safeDown() 25 | { 26 | echo "m200818_021303_update_rules_table cannot be reverted.\n"; 27 | $this->renameColumn('{{%rule}}', 'then_from_account_id', 'then_account_id'); 28 | $this->dropColumn('{{%rule}}', 'then_to_account_id'); 29 | $this->addColumn('{{%rule}}', 'then_direction', $this->tinyInteger()->after('then_transaction_type')); 30 | $this->renameColumn('{{%rule}}', 'then_transaction_type', 'if_direction'); 31 | return true; 32 | } 33 | 34 | /* 35 | // Use up()/down() to run migration code without a transaction. 36 | public function up() 37 | { 38 | 39 | } 40 | 41 | public function down() 42 | { 43 | echo "m200818_021303_update_rules_table cannot be reverted.\n"; 44 | 45 | return false; 46 | } 47 | */ 48 | } 49 | -------------------------------------------------------------------------------- /migrations/m200818_130329_create_transaction_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%transaction}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'from_account_id' => $this->integer(), 19 | 'to_account_id' => $this->integer(), 20 | 'type' => $this->tinyInteger()->notNull(), 21 | 'category_id' => $this->integer()->notNull(), 22 | 'amount_cent' => $this->integer()->notNull(), // base currency 23 | 'currency_amount_cent' => $this->integer()->notNull(), 24 | 'currency_code' => $this->string(3)->notNull(), 25 | 'tags' => $this->string()->comment('Multiple choice use,'), 26 | 'description' => $this->string(), 27 | 'remark' => $this->string(), 28 | 'image' => $this->string(), 29 | 'status' => $this->tinyInteger()->defaultValue(1), 30 | 'reimbursement_status' => $this->tinyInteger(), 31 | 'rating' => $this->tinyInteger(), 32 | 'date' => $this->timestamp()->notNull(), 33 | 'created_at' => $this->timestamp()->defaultValue(null), 34 | 'updated_at' => $this->timestamp()->defaultValue(null), 35 | ]); 36 | 37 | $this->createIndex('transaction_user_id', '{{%transaction}}', 'user_id'); 38 | 39 | $this->execute("ALTER TABLE {{%transaction}} ADD FULLTEXT INDEX `full_text` (`description`, `tags`, `remark`)"); 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function safeDown() 46 | { 47 | $this->dropTable('{{%transaction}}'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /migrations/m200818_131101_create_record_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%record}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'account_id' => $this->integer()->notNull(), 19 | 'category_id' => $this->integer()->notNull(), 20 | 'amount_cent' => $this->integer()->notNull(), // base currency 21 | 'currency_amount_cent' => $this->integer()->notNull(), 22 | 'currency_code' => $this->string(3)->notNull(), 23 | 'transaction_id' => $this->integer(), 24 | 'direction' => $this->tinyInteger()->notNull(), 25 | 'date' => $this->timestamp()->notNull(), 26 | 'created_at' => $this->timestamp()->defaultValue(null), 27 | 'updated_at' => $this->timestamp()->defaultValue(null), 28 | ]); 29 | 30 | $this->createIndex('record_user_id_transaction_id', '{{%record}}', ['user_id', 'transaction_id']); 31 | $this->createIndex('record_user_id_account_id', '{{%record}}', ['user_id', 'account_id']); 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function safeDown() 38 | { 39 | $this->dropTable('{{%record}}'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /migrations/m200821_031033_update_category_table.php: -------------------------------------------------------------------------------- 1 | renameColumn('{{%category}}', 'direction', 'transaction_type'); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function safeDown() 22 | { 23 | echo "m200821_031033_update_category_table cannot be reverted.\n"; 24 | $this->renameColumn('{{%category}}', 'transaction_type', 'direction'); 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/m200821_031118_update_account_table.php: -------------------------------------------------------------------------------- 1 | addColumn('{{%account}}', 'currency_balance_cent', $this->bigInteger()->after('balance_cent')); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function safeDown() 22 | { 23 | echo "m200821_031118_update_account_table cannot be reverted.\n"; 24 | $this->dropColumn('{{%account}}', 'currency_balance_cent'); 25 | 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /migrations/m200823_094104_create_auth_client_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%auth_client}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'type' => $this->tinyInteger()->notNull(), 19 | 'client_id' => $this->string()->notNull(), 20 | 'client_username' => $this->string(), 21 | 'data' => $this->text(), 22 | 'status' => $this->tinyInteger()->notNull(), 23 | 'created_at' => $this->timestamp()->defaultValue(null), 24 | 'updated_at' => $this->timestamp()->defaultValue(null), 25 | ]); 26 | 27 | $this->createIndex('login_user_id_type', '{{%auth_client}}', ['user_id', 'type'], true); 28 | $this->createIndex('login_type_client_id', '{{%auth_client}}', ['type', 'client_id'], true); 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function safeDown() 35 | { 36 | $this->dropTable('{{%auth_client}}'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /migrations/m200828_095405_update_record_table.php: -------------------------------------------------------------------------------- 1 | alterColumn('{{record}}', 'date', $this->timestamp()->defaultValue(null)); 16 | $this->alterColumn('{{transaction}}', 'date', $this->timestamp()->defaultValue(null)); 17 | 18 | $this->addColumn('{{record}}', 'transaction_type', $this->tinyInteger()->after('account_id')); 19 | $this->addColumn('{{record}}', 'source', $this->tinyInteger()->after('date')); 20 | } 21 | 22 | /** 23 | * {@inheritdoc} 24 | */ 25 | public function safeDown() 26 | { 27 | echo "m200828_095405_update_record_table cannot be reverted.\n"; 28 | $this->dropColumn('{{record}}', 'transaction_type'); 29 | $this->dropColumn('{{record}}', 'source'); 30 | 31 | return true; 32 | } 33 | 34 | /* 35 | // Use up()/down() to run migration code without a transaction. 36 | public function up() 37 | { 38 | 39 | } 40 | 41 | public function down() 42 | { 43 | echo "m200828_095405_update_record_table cannot be reverted.\n"; 44 | 45 | return false; 46 | } 47 | */ 48 | } 49 | -------------------------------------------------------------------------------- /migrations/m200830_030728_create_sort_column.php: -------------------------------------------------------------------------------- 1 | addColumn('{{rule}}', 'sort', $this->tinyInteger()->defaultValue(99)->after('status')); 16 | $this->addColumn('{{account}}', 'sort', $this->tinyInteger()->defaultValue(99)->after('default')); 17 | $this->addColumn('{{category}}', 'sort', $this->tinyInteger()->defaultValue(99)->after('default')); 18 | } 19 | 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function safeDown() 24 | { 25 | echo "m200830_030728_create_sort_column cannot be reverted.\n"; 26 | $this->dropColumn('{{rule}}', 'sort'); 27 | $this->dropColumn('{{account}}', 'sort'); 28 | $this->dropColumn('{{category}}', 'sort'); 29 | 30 | return true; 31 | } 32 | 33 | /* 34 | // Use up()/down() to run migration code without a transaction. 35 | public function up() 36 | { 37 | 38 | } 39 | 40 | public function down() 41 | { 42 | echo "m200830_030728_create_sort_column cannot be reverted.\n"; 43 | 44 | return false; 45 | } 46 | */ 47 | } 48 | -------------------------------------------------------------------------------- /migrations/m200909_030624_create_recurrence_table.php: -------------------------------------------------------------------------------- 1 | createTable('{{%recurrence}}', [ 16 | 'id' => $this->primaryKey(), 17 | 'user_id' => $this->integer()->notNull(), 18 | 'name' => $this->string()->notNull(), 19 | 'frequency' => $this->tinyInteger()->notNull(), 20 | 'interval' => $this->tinyInteger()->defaultValue(1), 21 | 'schedule' => $this->string(), 22 | 'transaction_id' => $this->integer()->notNull(), 23 | 'started_at' => $this->timestamp()->defaultValue(null), 24 | 'execution_date' => $this->timestamp()->defaultValue(null), 25 | 'status' => $this->tinyInteger()->defaultValue(1), 26 | 'created_at' => $this->timestamp()->defaultValue(null), 27 | 'updated_at' => $this->timestamp()->defaultValue(null), 28 | ]); 29 | 30 | $this->createIndex('record_user_id_transaction_id', '{{%recurrence}}', ['user_id', 'transaction_id']); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function safeDown() 37 | { 38 | $this->dropTable('{{%recurrence}}'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /migrations/m200917_064807_create_exclude_from_stats_column.php: -------------------------------------------------------------------------------- 1 | addColumn('{{%record}}', 'exclude_from_stats', $this->tinyInteger()->defaultValue(0)->after('source')); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function safeDown() 22 | { 23 | echo "m200917_064807_create_exclude_from_stats_column cannot be reverted.\n"; 24 | $this->dropColumn('{{%record}}', 'exclude_from_stats'); 25 | return true; 26 | } 27 | 28 | /* 29 | // Use up()/down() to run migration code without a transaction. 30 | public function up() 31 | { 32 | 33 | } 34 | 35 | public function down() 36 | { 37 | echo "m200917_064807_create_exclude_from_stats_column cannot be reverted.\n"; 38 | 39 | return false; 40 | } 41 | */ 42 | } 43 | -------------------------------------------------------------------------------- /modules/v1/Module.php: -------------------------------------------------------------------------------- 1 | SORT_ASC, 'id' => SORT_DESC]; 25 | public $partialMatchAttributes = ['name']; 26 | public $stringToIntAttributes = ['type' => AccountType::class, 'status' => AccountStatus::class]; 27 | 28 | public function actions() 29 | { 30 | $actions = parent::actions(); 31 | // 注销系统自带的实现方法 32 | unset($actions['update'], $actions['create']); 33 | return $actions; 34 | } 35 | 36 | /** 37 | * @return Account 38 | * @throws Exception 39 | */ 40 | public function actionCreate() 41 | { 42 | $params = Yii::$app->request->bodyParams; 43 | $model = new Account(); 44 | $model->user_id = 0; 45 | if (data_get($params, 'type') == AccountType::CREDIT_CARD) { 46 | $model->setScenario(AccountType::CREDIT_CARD); 47 | } 48 | /** @var Account $model */ 49 | $model = $this->validate($model, $params); 50 | 51 | return $this->accountService->createUpdate($model); 52 | } 53 | 54 | /** 55 | * @param int $id 56 | * @return Account 57 | * @throws NotFoundHttpException 58 | * @throws Exception 59 | */ 60 | public function actionUpdate(int $id) 61 | { 62 | $params = Yii::$app->request->bodyParams; 63 | if (!$model = AccountService::findCurrentOne($id)) { 64 | throw new NotFoundHttpException(); 65 | } 66 | 67 | if (data_get($params, 'type') == AccountType::CREDIT_CARD) { 68 | $model->setScenario(AccountType::CREDIT_CARD); 69 | } 70 | /** @var Account $model */ 71 | $model = $this->validate($model, $params); 72 | 73 | return $this->accountService->createUpdate($model); 74 | } 75 | 76 | 77 | /** 78 | * @return array 79 | * @throws Exception 80 | */ 81 | public function actionTypes() 82 | { 83 | $items = []; 84 | $texts = AccountType::texts(); 85 | foreach (AccountType::names() as $key => $name) { 86 | $items[] = ['type' => $name, 'name' => data_get($texts, $key)]; 87 | } 88 | return $items; 89 | } 90 | 91 | /** 92 | * @return array 93 | */ 94 | public function actionOverview() 95 | { 96 | $balanceCentSum = Account::find() 97 | ->where(['user_id' => Yii::$app->user->id, 'exclude_from_stats' => false]) 98 | ->sum('balance_cent'); 99 | $items['net_asset'] = $balanceCentSum ? Setup::toYuan($balanceCentSum) : 0; 100 | 101 | $balanceCentSum = Account::find() 102 | ->where(['user_id' => Yii::$app->user->id, 'exclude_from_stats' => false]) 103 | ->andWhere(['>', 'balance_cent', 0]) 104 | ->sum('balance_cent'); 105 | $items['total_assets'] = $balanceCentSum ? Setup::toYuan($balanceCentSum) : 0; 106 | 107 | $balanceCentSum = Account::find() 108 | ->where(['user_id' => Yii::$app->user->id, 'exclude_from_stats' => false]) 109 | ->andWhere(['<', 'balance_cent', 0]) 110 | ->sum('balance_cent'); 111 | $items['liabilities'] = $balanceCentSum ? Setup::toYuan($balanceCentSum) : 0; 112 | 113 | $items['count'] = Account::find() 114 | ->where(['user_id' => Yii::$app->user->id, 'exclude_from_stats' => false]) 115 | ->count('id'); 116 | 117 | return $items; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /modules/v1/controllers/ActiveController.php: -------------------------------------------------------------------------------- 1 | SORT_DESC]; 29 | public $partialMatchAttributes = []; 30 | public $stringToIntAttributes = []; 31 | public $relations = []; 32 | 33 | /** 34 | * 不参与校验的 actions 35 | * @var array 36 | */ 37 | public $noAuthActions = []; 38 | 39 | // 序列化输出 40 | public $serializer = [ 41 | 'class' => 'yii\rest\Serializer', 42 | 'collectionEnvelope' => 'items', 43 | ]; 44 | 45 | public function behaviors() 46 | { 47 | $behaviors = parent::behaviors(); 48 | 49 | // 跨区请求 必须先删掉 authenticator 50 | $behaviors['authenticator']; 51 | unset($behaviors['authenticator']); 52 | 53 | $behaviors['corsFilter'] = [ 54 | 'class' => Cors::class, 55 | 'cors' => [ 56 | 'Origin' => ['*'], 57 | 'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 58 | 'Access-Control-Request-Headers' => ['*'], 59 | 'Access-Control-Max-Age' => 86400, 60 | ] 61 | ]; 62 | $behaviors['authenticator'] = [ 63 | 'class' => JwtHttpBearerAuth::class, 64 | 'optional' => array_merge($this->noAuthActions, ['options']), 65 | ]; 66 | 67 | return $behaviors; 68 | } 69 | 70 | public function actions() 71 | { 72 | $actions = parent::actions(); 73 | $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider']; 74 | $actions['create']['class'] = CreateAction::class; 75 | return $actions; 76 | } 77 | 78 | /** 79 | * @return ActiveDataProvider 80 | * @throws InvalidArgumentException 81 | * @throws InternalException 82 | * @throws InvalidConfigException 83 | * @throws \Exception 84 | */ 85 | public function prepareDataProvider() 86 | { 87 | /** @var ActiveRecord $modelClass */ 88 | $modelClass = $this->modelClass; 89 | $searchModel = new SearchModel([ 90 | 'defaultOrder' => $this->defaultOrder, 91 | 'model' => $modelClass, 92 | 'relations' => $this->relations, 93 | 'scenario' => 'default', 94 | 'partialMatchAttributes' => $this->partialMatchAttributes, 95 | 'pageSize' => $this->getPageSize() 96 | ]); 97 | 98 | $params = $this->formatParams(Yii::$app->request->queryParams); 99 | foreach ($this->stringToIntAttributes as $attribute => $className) { 100 | if ($type = data_get($params, $attribute)) { 101 | $params[$attribute] = SearchHelper::stringToInt($type, $className); 102 | } 103 | } 104 | unset($params['sort']); 105 | 106 | $dataProvider = $searchModel->search(['SearchModel' => $params]); 107 | $dataProvider->query->andWhere([$modelClass::tableName() . '.user_id' => Yii::$app->user->id]); 108 | return $dataProvider; 109 | } 110 | 111 | 112 | protected function formatParams(array $params) 113 | { 114 | return $params; 115 | } 116 | 117 | /** 118 | * @return int 119 | */ 120 | protected function getPageSize() 121 | { 122 | if ($pageSize = (int)request('pageSize')) { 123 | if ($pageSize < self::MAX_PAGE_SIZE) { 124 | return $pageSize; 125 | } 126 | return self::MAX_PAGE_SIZE; 127 | } 128 | return self::DEFAULT_PAGE_SIZE; 129 | } 130 | 131 | 132 | /** 133 | * @param Model $model 134 | * @param array $params 135 | * @return Model 136 | * @throws InvalidArgumentException 137 | */ 138 | public function validate(Model $model, array $params): Model 139 | { 140 | $model->load($params, ''); 141 | if (!$model->validate()) { 142 | throw new InvalidArgumentException(Setup::errorMessage($model->firstErrors)); 143 | } 144 | return $model; 145 | } 146 | 147 | /** 148 | * @param string $action 149 | * @param null $model 150 | * @param array $params 151 | * @throws ForbiddenHttpException 152 | */ 153 | public function checkAccess($action, $model = null, $params = []) 154 | { 155 | if (in_array($action, ['delete', 'update', 'view'])) { 156 | if ($model->user_id !== \Yii::$app->user->id) { 157 | throw new ForbiddenHttpException( 158 | t('app', 'You can only ' . $action . ' data that you\'ve created.') 159 | ); 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /modules/v1/controllers/AnalysisController.php: -------------------------------------------------------------------------------- 1 | request->queryParams; 31 | return $this->analysisService->byCategory($params); 32 | } 33 | 34 | /** 35 | * @return array 36 | * @throws InvalidArgumentException 37 | */ 38 | public function actionDate() 39 | { 40 | $params = \Yii::$app->request->queryParams; 41 | $groupByDateType = request('group_type') ?: AnalysisGroupDateType::DAY; 42 | return $this->analysisService->byDate($params, AnalysisGroupDateType::getValue($groupByDateType)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /modules/v1/controllers/CategoryController.php: -------------------------------------------------------------------------------- 1 | SORT_ASC, 'id' => SORT_DESC]; 21 | public $partialMatchAttributes = ['name']; 22 | public $stringToIntAttributes = ['transaction_type' => TransactionType::class]; 23 | 24 | /** 25 | * @return array 26 | * @throws InvalidArgumentException 27 | * @throws \Exception 28 | */ 29 | public function actionAnalysis() 30 | { 31 | $transactionType = request('transaction_type', TransactionType::getName(TransactionType::EXPENSE)); 32 | $dateType = request('date_type', AnalysisDateType::CURRENT_MONTH); 33 | $date = AnalysisService::getDateRange($dateType); 34 | 35 | return $this->analysisService->getCategoryStatisticalData( 36 | $date, 37 | TransactionType::toEnumValue($transactionType) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /modules/v1/controllers/RecordController.php: -------------------------------------------------------------------------------- 1 | SORT_DESC, 'id' => SORT_DESC]; 27 | public $stringToIntAttributes = ['transaction_type' => TransactionType::class]; 28 | public $relations = ['transaction' => []]; 29 | 30 | public function actions() 31 | { 32 | $actions = parent::actions(); 33 | unset($actions['create'], $actions['update']); 34 | return $actions; 35 | } 36 | 37 | 38 | /** 39 | * @return ActiveDataProvider 40 | * @throws InvalidArgumentException 41 | * @throws InvalidConfigException 42 | * @throws InternalException 43 | */ 44 | public function prepareDataProvider() 45 | { 46 | $dataProvider = parent::prepareDataProvider(); 47 | if ($searchKeywords = trim(request('keyword'))) { 48 | $dataProvider->query->andWhere( 49 | "MATCH(`description`, `tags`, `remark`) AGAINST ('*$searchKeywords*' IN BOOLEAN MODE)" 50 | ); 51 | } 52 | $dataProvider->setModels($this->transactionService->formatRecords($dataProvider->getModels())); 53 | return $dataProvider; 54 | } 55 | 56 | /** 57 | * @param array $params 58 | * @return array 59 | * @throws Exception 60 | */ 61 | protected function formatParams(array $params) 62 | { 63 | if (($date = explode('~', data_get($params, 'date'))) && count($date) == 2) { 64 | $start = $date[0] . ' 00:00:00'; 65 | $end = $date[1] . ' 23:59:59'; 66 | $params['date'] = "{$start}~{$end}"; 67 | } 68 | return $params; 69 | } 70 | 71 | /** 72 | * @return array 73 | * @throws Exception 74 | */ 75 | public function actionOverview() 76 | { 77 | return array_values($this->analysisService->recordOverview); 78 | } 79 | 80 | 81 | /** 82 | * @return array 83 | * @throws InvalidArgumentException 84 | * @throws Exception 85 | */ 86 | public function actionAnalysis() 87 | { 88 | $transactionType = request('transaction_type', TransactionType::getName(TransactionType::EXPENSE)); 89 | $date = request('date', Yii::$app->formatter->asDatetime('now')); 90 | 91 | return $this->analysisService->getRecordStatisticalData( 92 | $date, 93 | TransactionType::toEnumValue($transactionType) 94 | ); 95 | } 96 | 97 | /** 98 | * @return array 99 | * @throws Exception 100 | */ 101 | public function actionSources() 102 | { 103 | $items = []; 104 | $names = RecordSource::names(); 105 | foreach ($names as $key => $name) { 106 | $items[] = ['type' => $key, 'name' => $name]; 107 | } 108 | return $items; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /modules/v1/controllers/RecurrenceController.php: -------------------------------------------------------------------------------- 1 | request->bodyParams; 36 | $model = new RecurrenceUpdateStatusRequest(); 37 | /** @var RecurrenceUpdateStatusRequest $model */ 38 | $model = $this->validate($model, $params); 39 | 40 | return $this->recurrenceService->updateStatus($id, $model->status); 41 | } 42 | 43 | 44 | /** 45 | * @return array 46 | * @throws \Exception 47 | */ 48 | public function actionFrequencyTypes() 49 | { 50 | $items = []; 51 | $texts = RecurrenceFrequency::texts(); 52 | foreach (RecurrenceFrequency::names() as $key => $name) { 53 | $items[] = ['type' => $name, 'name' => data_get($texts, $key)]; 54 | } 55 | return $items; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /modules/v1/controllers/RuleController.php: -------------------------------------------------------------------------------- 1 | SORT_ASC, 'id' => SORT_DESC]; 22 | public $partialMatchAttributes = ['name']; 23 | 24 | /** 25 | * @param int $id 26 | * @return Rule 27 | * @throws Exception 28 | * @throws NotFoundHttpException 29 | */ 30 | public function actionCopy(int $id): Rule 31 | { 32 | return $this->ruleService->copy($id); 33 | } 34 | 35 | /** 36 | * @param int $id 37 | * @return Rule 38 | * @throws Exception 39 | * @throws InvalidArgumentException 40 | * @throws NotFoundHttpException 41 | */ 42 | public function actionUpdateStatus(int $id): Rule 43 | { 44 | $params = Yii::$app->request->bodyParams; 45 | $model = new RuleUpdateStatusRequest(); 46 | /** @var RuleUpdateStatusRequest $model */ 47 | $model = $this->validate($model, $params); 48 | 49 | return $this->ruleService->updateStatus($id, $model->status); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /modules/v1/controllers/TagController.php: -------------------------------------------------------------------------------- 1 | SORT_DESC, 'id' => SORT_DESC]; 14 | public $partialMatchAttributes = ['name']; 15 | } 16 | -------------------------------------------------------------------------------- /modules/v1/controllers/TransactionController.php: -------------------------------------------------------------------------------- 1 | request->bodyParams; 38 | $model = new TransactionCreateByDescRequest(); 39 | /** @var TransactionCreateByDescRequest $model */ 40 | $model = $this->validate($model, $params); 41 | 42 | return $this->transactionService->createByDesc($model->description); 43 | } 44 | 45 | /** 46 | * @return array 47 | * @throws \Exception 48 | */ 49 | public function actionTypes() 50 | { 51 | $items = []; 52 | $texts = TransactionType::texts(); 53 | $names = TransactionType::names(); 54 | // unset($names[TransactionType::ADJUST]); 55 | foreach ($names as $key => $name) { 56 | $items[] = ['type' => $name, 'name' => data_get($texts, $key)]; 57 | } 58 | return $items; 59 | } 60 | 61 | 62 | /** 63 | * @return array 64 | * @throws InvalidArgumentException 65 | * @throws \Exception 66 | */ 67 | public function actionUpload() 68 | { 69 | $fileParam = 'file'; 70 | $uploadedFile = UploadedFile::getInstanceByName($fileParam); 71 | $params = [$fileParam => $uploadedFile]; 72 | $model = new TransactionUploadRequest(); 73 | $this->validate($model, $params); 74 | $filename = Yii::$app->user->id . 'record.csv'; 75 | $this->uploadService->uploadRecord($uploadedFile, $filename); 76 | $data = $this->transactionService->createByCSV($filename); 77 | $this->uploadService->deleteLocalFile($filename); 78 | return $data; 79 | } 80 | 81 | 82 | /** 83 | * @return array 84 | * @throws \Exception 85 | */ 86 | public function actionExport() 87 | { 88 | return $this->transactionService->exportData(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /modules/v1/controllers/UserController.php: -------------------------------------------------------------------------------- 1 | request->bodyParams; 39 | $data = $this->validate(new JoinRequest(), $params); 40 | 41 | /** @var JoinRequest $data */ 42 | return $this->userService->createUser($data); 43 | } 44 | 45 | /** 46 | * @return string[] 47 | * @throws InvalidArgumentException|\Throwable 48 | */ 49 | public function actionLogin() 50 | { 51 | $params = Yii::$app->request->bodyParams; 52 | $this->validate(new LoginRequest(), $params); 53 | $token = $this->userService->getToken(); 54 | $user = Yii::$app->user->identity; 55 | 56 | return [ 57 | 'user' => $user, 58 | 'token' => (string)$token, 59 | ]; 60 | } 61 | 62 | public function actionRefreshToken() 63 | { 64 | $user = Yii::$app->user->identity; 65 | $token = $this->userService->getToken(); 66 | return [ 67 | 'user' => $user, 68 | 'token' => (string)$token, 69 | ]; 70 | } 71 | 72 | /** 73 | * @return array 74 | * @throws \yii\base\Exception 75 | */ 76 | public function actionResetToken() 77 | { 78 | /** @var User $user */ 79 | $user = Yii::$app->user->identity; 80 | $this->userService->setPasswordResetToken($user); 81 | return [ 82 | 'reset_token' => $user->password_reset_token, 83 | 'expire_in' => params('user.passwordResetTokenExpire') 84 | ]; 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function actionGetAuthClients() 91 | { 92 | return $this->userService->getAuthClients(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /requirements.php: -------------------------------------------------------------------------------- 1 | Error\n\n" 34 | . "

The path to yii framework seems to be incorrect.

\n" 35 | . '

You need to install Yii framework via composer or adjust the framework path in file ' . basename(__FILE__) . ".

\n" 36 | . '

Please refer to the README on how to install Yii.

\n"; 37 | 38 | if (!empty($_SERVER['argv'])) { 39 | // do not print HTML when used in console mode 40 | echo strip_tags($message); 41 | } else { 42 | echo $message; 43 | } 44 | exit(1); 45 | } 46 | 47 | require_once($frameworkPath . '/requirements/YiiRequirementChecker.php'); 48 | $requirementsChecker = new YiiRequirementChecker(); 49 | 50 | $gdMemo = $imagickMemo = 'Either GD PHP extension with FreeType support or ImageMagick PHP extension with PNG support is required for image CAPTCHA.'; 51 | $gdOK = $imagickOK = false; 52 | 53 | if (extension_loaded('imagick')) { 54 | $imagick = new Imagick(); 55 | $imagickFormats = $imagick->queryFormats('PNG'); 56 | if (in_array('PNG', $imagickFormats)) { 57 | $imagickOK = true; 58 | } else { 59 | $imagickMemo = 'Imagick extension should be installed with PNG support in order to be used for image CAPTCHA.'; 60 | } 61 | } 62 | 63 | if (extension_loaded('gd')) { 64 | $gdInfo = gd_info(); 65 | if (!empty($gdInfo['FreeType Support'])) { 66 | $gdOK = true; 67 | } else { 68 | $gdMemo = 'GD extension should be installed with FreeType support in order to be used for image CAPTCHA.'; 69 | } 70 | } 71 | 72 | /** 73 | * Adjust requirements according to your application specifics. 74 | */ 75 | $requirements = array( 76 | // Database : 77 | array( 78 | 'name' => 'PDO extension', 79 | 'mandatory' => true, 80 | 'condition' => extension_loaded('pdo'), 81 | 'by' => 'All DB-related classes', 82 | ), 83 | array( 84 | 'name' => 'PDO SQLite extension', 85 | 'mandatory' => false, 86 | 'condition' => extension_loaded('pdo_sqlite'), 87 | 'by' => 'All DB-related classes', 88 | 'memo' => 'Required for SQLite database.', 89 | ), 90 | array( 91 | 'name' => 'PDO MySQL extension', 92 | 'mandatory' => false, 93 | 'condition' => extension_loaded('pdo_mysql'), 94 | 'by' => 'All DB-related classes', 95 | 'memo' => 'Required for MySQL database.', 96 | ), 97 | array( 98 | 'name' => 'PDO PostgreSQL extension', 99 | 'mandatory' => false, 100 | 'condition' => extension_loaded('pdo_pgsql'), 101 | 'by' => 'All DB-related classes', 102 | 'memo' => 'Required for PostgreSQL database.', 103 | ), 104 | // Cache : 105 | array( 106 | 'name' => 'Memcache extension', 107 | 'mandatory' => false, 108 | 'condition' => extension_loaded('memcache') || extension_loaded('memcached'), 109 | 'by' => 'MemCache', 110 | 'memo' => extension_loaded('memcached') ? 'To use memcached set MemCache::useMemcached to true.' : '' 111 | ), 112 | // CAPTCHA: 113 | array( 114 | 'name' => 'GD PHP extension with FreeType support', 115 | 'mandatory' => false, 116 | 'condition' => $gdOK, 117 | 'by' => 'Captcha', 118 | 'memo' => $gdMemo, 119 | ), 120 | array( 121 | 'name' => 'ImageMagick PHP extension with PNG support', 122 | 'mandatory' => false, 123 | 'condition' => $imagickOK, 124 | 'by' => 'Captcha', 125 | 'memo' => $imagickMemo, 126 | ), 127 | // PHP ini : 128 | 'phpExposePhp' => array( 129 | 'name' => 'Expose PHP', 130 | 'mandatory' => false, 131 | 'condition' => $requirementsChecker->checkPhpIniOff("expose_php"), 132 | 'by' => 'Security reasons', 133 | 'memo' => '"expose_php" should be disabled at php.ini', 134 | ), 135 | 'phpAllowUrlInclude' => array( 136 | 'name' => 'PHP allow url include', 137 | 'mandatory' => false, 138 | 'condition' => $requirementsChecker->checkPhpIniOff("allow_url_include"), 139 | 'by' => 'Security reasons', 140 | 'memo' => '"allow_url_include" should be disabled at php.ini', 141 | ), 142 | 'phpSmtp' => array( 143 | 'name' => 'PHP mail SMTP', 144 | 'mandatory' => false, 145 | 'condition' => strlen(ini_get('SMTP')) > 0, 146 | 'by' => 'Email sending', 147 | 'memo' => 'PHP mail SMTP server required', 148 | ), 149 | ); 150 | 151 | // OPcache check 152 | if (!version_compare(phpversion(), '5.5', '>=')) { 153 | $requirements[] = array( 154 | 'name' => 'APC extension', 155 | 'mandatory' => false, 156 | 'condition' => extension_loaded('apc'), 157 | 'by' => 'ApcCache', 158 | ); 159 | } 160 | 161 | $result = $requirementsChecker->checkYii()->check($requirements)->getResult(); 162 | $requirementsChecker->render(); 163 | exit($result['summary']['errors'] === 0 ? 0 : 1); 164 | -------------------------------------------------------------------------------- /runtime/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /tests/_bootstrap.php: -------------------------------------------------------------------------------- 1 | [ 29 | 'username' => 'demo', 30 | 'email' => 'demo@yii.com', 31 | 'password' => 'pass123', 32 | 'base_currency_code' => 'CNS', 33 | ], 34 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 35 | ], 36 | [ 37 | 'data' => [ 38 | 'username' => 'demo', 39 | 'email' => 'demo@yii.com', 40 | 'password' => 'pass123', 41 | ], 42 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 43 | ], 44 | [ 45 | 'data' => [ 46 | 'username' => 'demo', 47 | 'email' => 'demo', 48 | 'password' => 'pass123', 49 | ], 50 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 51 | ], 52 | [ 53 | 'data' => [ 54 | 'username' => 'demo-sdsdkj', 55 | 'email' => 'demo@yii.com', 56 | 'password' => 'pass123', 57 | ], 58 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 59 | ], 60 | [ 61 | 'data' => [ 62 | 'username' => 'demo-sdsdkj', 63 | 'email' => 'demo@yii.com', 64 | 'password' => 'pass1', 65 | ], 66 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 67 | ], 68 | [ 69 | 'data' => [ 70 | 'username' => 'demo-sdsdkj', 71 | 'password' => 'pass1', 72 | ], 73 | 'code' => ErrorCodes::INVALID_ARGUMENT_ERROR 74 | ], 75 | ]; 76 | } 77 | 78 | /** 79 | * @dataProvider makeValidateFailItems 80 | * @param ApiTester $I 81 | * @param Example $example 82 | */ 83 | public function createUserViaAPIFail(ApiTester $I, Example $example) 84 | { 85 | $I->haveHttpHeader('content-type', 'application/json'); 86 | $I->sendPOST('/join', $example['data']); 87 | $I->seeResponseCodeIs(HttpCode::OK); // 200 88 | $I->seeResponseIsJson(); 89 | $I->seeResponseContainsJson(['code' => $example['code']]); 90 | } 91 | 92 | /** 93 | * @param ApiTester $I 94 | */ 95 | public function createUser(ApiTester $I) 96 | { 97 | $I->haveHttpHeader('content-type', 'application/json'); 98 | $I->sendPOST('/join', [ 99 | 'username' => 'demo', 100 | 'email' => 'demo@yii.com', 101 | 'password' => 'pass123', 102 | 'base_currency_code' => 'CNY', 103 | ]); 104 | $I->seeResponseCodeIs(HttpCode::OK); // 200 105 | $I->seeResponseIsJson(); 106 | $I->seeResponseContainsJson(['code' => 0]); 107 | $I->seeResponseJsonMatchesXpath('//data/username'); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/api/LoginUserCest.php: -------------------------------------------------------------------------------- 1 | haveHttpHeader('content-type', 'application/json'); 27 | $I->sendPOST('/login', [ 28 | 'username' => 'demo', 29 | 'password' => 'pass123456', 30 | ]); 31 | $I->seeResponseCodeIs(HttpCode::OK); // 200 32 | $I->seeResponseIsJson(); 33 | $I->seeResponseContainsJson(['code' => ErrorCodes::INVALID_ARGUMENT_ERROR]); 34 | } 35 | 36 | 37 | /** 38 | * @param ApiTester $I 39 | * @param CreateUserCest $createUserCest 40 | */ 41 | public function userLogin(ApiTester $I, CreateUserCest $createUserCest) 42 | { 43 | $createUserCest->createUser($I); 44 | $I->haveHttpHeader('content-type', 'application/json'); 45 | $I->sendPOST('/login', [ 46 | 'username' => 'demo', 47 | 'password' => 'pass123', 48 | ]); 49 | $I->seeResponseCodeIs(HttpCode::OK); // 200 50 | $I->seeResponseIsJson(); 51 | $I->seeResponseContainsJson(['code' => 0]); 52 | $I->seeResponseJsonMatchesXpath('//data/token'); 53 | $I->seeResponseJsonMatchesXpath('//data/user/username'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /web/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 3 | 4 | RewriteEngine on 5 | 6 | # Authorization Headers 7 | #RewriteCond %{HTTP:Authorization} . 8 | #RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 9 | 10 | RewriteCond %{REQUEST_FILENAME} !-f 11 | RewriteCond %{REQUEST_FILENAME} !-d 12 | 13 | RewriteRule . index.php 14 | -------------------------------------------------------------------------------- /web/assets/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /web/index.php: -------------------------------------------------------------------------------- 1 | run(); 12 | -------------------------------------------------------------------------------- /web/uploads/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !import-template.csv 4 | -------------------------------------------------------------------------------- /web/uploads/import-template.csv: -------------------------------------------------------------------------------- 1 | 账单日期,类别,类型,金额,标签,描述,备注,账户1,账户2 2 | 2020-07-31,餐饮食品,支出,10,早餐/三餐,昨天晚餐10,示例数据,, 3 | 2020-07-31,转账,转账,15,,,示例数据,测试账户,测试 4 | -------------------------------------------------------------------------------- /yii: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 22 | exit($exitCode); 23 | -------------------------------------------------------------------------------- /yii.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | rem ------------------------------------------------------------- 4 | rem Yii command line bootstrap script for Windows. 5 | rem 6 | rem @author Qiang Xue 7 | rem @link http://www.yiiframework.com/ 8 | rem @copyright Copyright (c) 2008 Yii Software LLC 9 | rem @license http://www.yiiframework.com/license/ 10 | rem ------------------------------------------------------------- 11 | 12 | @setlocal 13 | 14 | set YII_PATH=%~dp0 15 | 16 | if "%PHP_COMMAND%" == "" set PHP_COMMAND=php.exe 17 | 18 | "%PHP_COMMAND%" "%YII_PATH%yii" %* 19 | 20 | @endlocal 21 | --------------------------------------------------------------------------------